Merge branch 'master' into feature/heroku-deploy

This commit is contained in:
Wyatt Johnson
2017-08-28 15:33:24 -06:00
committed by GitHub
265 changed files with 4815 additions and 5033 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ node_modules
scripts
!scripts/generateIntrospectionResult.js
# documentation should not be visable in production.
# documentation should not be visible in production.
docs
# static assets are rebuild in the docker container.
@@ -14,6 +14,7 @@ dist
# tests are not run in the docker container.
test
__tests__
# we won't use the .git folder in production.
.git
+6 -1
View File
@@ -1,6 +1,5 @@
dist
docs
client/lib
**/*.html
plugins/*
!plugins/talk-plugin-facebook-auth
@@ -14,5 +13,11 @@ plugins/*
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-respected
node_modules
+6 -1
View File
@@ -14,7 +14,6 @@ client/coral-framework/graphql/introspection.json
*.swp
*.DS_STORE
test/e2e/reports
coverage/
plugins.json
@@ -30,5 +29,11 @@ plugins/*
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-respected
**/node_modules/*
+4 -4
View File
@@ -1,4 +1,4 @@
FROM node:8
FROM node:8-alpine
# Create app directory
RUN mkdir -p /usr/src/app
@@ -12,6 +12,9 @@ EXPOSE 5000
# Bundle app source
COPY . /usr/src/app
# Ensure the runtime of the container is in production mode.
ENV NODE_ENV production
# Install app dependencies and build static assets.
RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
@@ -19,7 +22,4 @@ RUN yarn global add node-gyp && \
yarn build && \
yarn cache clean
# Ensure the runtime of the container is in production mode.
ENV NODE_ENV production
CMD ["yarn", "start"]
+2 -5
View File
@@ -7,8 +7,5 @@ ONBUILD COPY . /usr/src/app
# we need to have webpack available. We then build the new dependancies and
# clear out the development dependancies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \
NODE_ENV=production cli plugins reconcile && \
NODE_ENV=production yarn build && \
NODE_ENV=production yarn install --production --force && \
yarn cache clean
ONBUILD RUN cli plugins reconcile && \
yarn build
+1
View File
@@ -16,6 +16,7 @@ program
.command('token', 'work with the access tokens')
.command('users', 'work with the application auth')
.command('migration', 'provides utilities for migrating the database')
.command('verify', 'provides utilities for performing data verification')
.command(
'plugins',
'provides utilities for interacting with the plugin system'
Executable
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const mongoose = require('../services/mongoose');
const util = require('./util');
const databaseVerifications = require('./verifications/database');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
async function database({fix = false, limit = Infinity, batch = 1000}) {
try {
for (const verification of databaseVerifications) {
await verification({fix, limit, batch});
}
} catch (err) {
console.error(`Failed to process all the ${databaseVerifications.length} verifications`, err);
util.shutdown(1);
return;
}
util.shutdown();
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('db')
.description('verifies the database integrity')
.option('-f, --fix', 'fix the problems found with database inconsistencies')
.option('-l, --limit [size]', 'limit the amount of documents to process in a single pass, this will ensure only a maximum number of batch operations are issued [default: inf]', parseInt)
.option('-b, --batch [size]', 'batch size to process verifications and repairs of documents [default: 1000]', parseInt)
.action(database);
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
+193
View File
@@ -0,0 +1,193 @@
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const {arrayJoinBy, singleJoinBy} = require('../../../graph/loaders/util');
const sc = require('snake-case');
const debug = require('debug')('talk:cli:verify');
const getBatch = async (limit, offset) => CommentModel
.find({})
.select({'id': 1, 'action_counts': 1, 'reply_count': 1})
.limit(limit)
.skip(offset)
.sort('created_at');
module.exports = async ({fix, limit, batch}) => {
let operations = [];
// Count how many comments there are to process.
const totalCount = await CommentModel.count();
let offset = 0;
let comments = [];
let commentIDs = [];
console.log(`Processing ${totalCount} comments in batches of ${limit}...`);
// Keep processing documents until there are is none left.
while (offset < totalCount) {
// Get a batch of comments.
comments = await getBatch(batch, offset);
commentIDs = comments.map(({id}) => id);
// Get their reply counts.
let allReplyCounts = await CommentModel
.aggregate([
{
$match: {
parent_id: {
$in: commentIDs,
},
status: {
$in: ['NONE', 'ACCEPTED']
}
}
},
{
$group: {
_id: '$parent_id',
count: {
$sum: 1
}
}
}
])
.then(singleJoinBy(commentIDs, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
// Get their action summaries.
let allActionSummaries = await ActionsService
.getActionSummaries(commentIDs)
.then(arrayJoinBy(commentIDs, 'item_id'));
// Loop over the comments, with their action summaries.
for (let i = 0; i < comments.length; i++) {
let comment = comments[i];
let actionSummaries = allActionSummaries[i];
let replyCount = allReplyCounts[i];
// And check to see if the action summaries we just computed match what is
// currently set for the comments.
let commentOperations = [];
// If the reply count needs to be updated, then update it!
if (comment.reply_count !== replyCount) {
commentOperations.push({
reply_count: replyCount,
});
}
// First we process all the group id's.
for (let actionSummary of actionSummaries) {
if (actionSummary.group_id === null) {
continue;
}
// And we generate the group id.
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
const GROUP_ID = sc(actionSummary.group_id.toLowerCase());
if (GROUP_ID.length <= 0) {
continue;
}
// And we add a new batch operation if the action summary is associated
// with a group.
const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`;
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== actionSummary.count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count,
});
}
}
// Group all the action summaries together from all the different group
// ids.
let groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => {
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
if (!(ACTION_TYPE in acc)) {
acc[ACTION_TYPE] = 0;
}
acc[ACTION_TYPE] += actionSummary.count;
return acc;
}, {});
for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) {
const count = groupedActionSummaries[ACTION_COUNT_FIELD];
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: count,
});
}
}
// If this comment has action summaries that should be updated, then
// perform an update!
if (commentOperations.length > 0) {
operations.push({
updateOne: {
filter: {
id: comment.id
},
update: {
$set: Object.assign({}, ...commentOperations),
},
},
});
}
}
debug(`Processed batch of ${comments.length} comments.`);
if (operations.length >= limit) {
debug(`Queued operations are ${operations.length}, reached limit of ${limit}, not processing any more.`);
if (operations.length > limit) {
debug(`${operations.length - limit} operations have been truncated to enforce the limit`);
}
break;
}
offset += batch;
}
const OPERATIONS_LENGTH = operations.length;
if (limit < Infinity && offset + comments.length < totalCount) {
console.log(`Processed ${offset + comments.length}/${totalCount} comments because we reached the update limit of ${limit}.`);
} else {
console.log(`Processed all ${totalCount} comments.`);
}
console.log(`${OPERATIONS_LENGTH} documents need fixing.`);
// If fix was enabled, execute the batch writes.
if (OPERATIONS_LENGTH > 0) {
if (fix) {
debug(`Fixing ${OPERATIONS_LENGTH} documents...`);
while (operations.length) {
let batchOperations = operations.splice(0, batch);
let result = await CommentModel.collection.bulkWrite(batchOperations);
debug(`Fixed batch of ${result.modifiedCount} documents.`);
}
console.log(`Applied all ${OPERATIONS_LENGTH} fixes.`);
} else {
console.warn('Skipping fixing, --fix was not enabled, pass --fix to fix these errors');
}
}
};
+12
View File
@@ -0,0 +1,12 @@
// This will import all the verifications that should be run by the:
//
// cli verify database
//
// command. They exist in the form:
//
// async ({fix = false, batch = 1000}) => {}
//
// where their options are derrived.
module.exports = [
require('./comments'),
];
-2
View File
@@ -40,8 +40,6 @@ test:
override:
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
# Run the e2e test suite.
# - E2E_REPORT_PATH=$CIRCLE_TEST_REPORTS/e2e yarn e2e
deployment:
release:
+2 -1
View File
@@ -9,6 +9,7 @@
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
"transform-react-jsx",
"syntax-dynamic-import"
]
}
+10 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
import {history} from 'coral-framework/helpers/router';
import PropTypes from 'prop-types';
import Configure from 'routes/Configure';
import Dashboard from 'routes/Dashboard';
@@ -47,6 +47,14 @@ const routes = (
</div>
);
const AppRouter = () => <Router history={history} routes={routes}/>;
class AppRouter extends React.Component {
static contextTypes = {
history: PropTypes.object,
};
render() {
return <Router history={this.context.history} routes={routes} />;
}
}
export default AppRouter;
+4 -5
View File
@@ -8,7 +8,6 @@ import {
UPDATE_ASSETS
} from '../constants/assets';
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
/**
@@ -17,9 +16,9 @@ import t from 'coral-framework/services/i18n';
// Fetch a page of assets
// Get comments to fill each of the three lists on the mod queue
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => {
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch, _, {rest}) => {
dispatch({type: FETCH_ASSETS_REQUEST});
return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
return rest(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
.then(({result, count}) =>
dispatch({type: FETCH_ASSETS_SUCCESS,
assets: result,
@@ -34,9 +33,9 @@ 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) => {
export const updateAssetState = (id, closedAt) => (dispatch, _, {rest}) => {
dispatch({type: UPDATE_ASSET_STATE_REQUEST, id, closedAt});
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
return rest(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
.then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch((error) => {
console.error(error);
+43 -16
View File
@@ -1,16 +1,13 @@
import bowser from 'bowser';
import * as actions from '../constants/auth';
import coralApi from 'coral-framework/helpers/request';
import * as Storage from 'coral-framework/helpers/storage';
import {handleAuthToken} from 'coral-framework/actions/auth';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
import jwtDecode from 'jwt-decode';
//==============================================================================
// SIGN IN
//==============================================================================
export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => {
export const handleLogin = (email, password, recaptchaResponse) => (dispatch, _, {rest, client, storage}) => {
dispatch({type: actions.LOGIN_REQUEST});
const params = {
@@ -27,18 +24,18 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
};
}
return coralApi('/auth/local', params)
return rest('/auth/local', params)
.then(({user, token}) => {
if (!user) {
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
}
return dispatch(checkLoginFailure('not logged in'));
}
dispatch(handleAuthToken(token));
resetWebsocket();
client.resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
@@ -84,11 +81,11 @@ const forgotPasswordFailure = (error) => ({
error,
});
export const requestPasswordReset = (email) => (dispatch) => {
export const requestPasswordReset = (email) => (dispatch, _, {rest}) => {
dispatch(forgotPasswordRequest(email));
const redirectUri = location.href;
return coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}})
return rest('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}})
.then(() => dispatch(forgotPasswordSuccess()))
.catch((error) => {
console.error(error);
@@ -116,18 +113,18 @@ const checkLoginFailure = (error) => ({
error
});
export const checkLogin = () => (dispatch) => {
export const checkLogin = () => (dispatch, _, {rest, client, storage}) => {
dispatch(checkLoginRequest());
return coralApi('/auth')
return rest('/auth')
.then(({user}) => {
if (!user) {
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
}
return dispatch(checkLoginFailure('not logged in'));
}
resetWebsocket();
client.resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
@@ -136,3 +133,33 @@ export const checkLogin = () => (dispatch) => {
dispatch(checkLoginFailure(errorMessage));
});
};
//==============================================================================
// LOGOUT
//==============================================================================
export const logout = () => (dispatch, _, {rest, client, storage}) => {
return rest('/auth', {method: 'DELETE'}).then(() => {
if (storage) {
storage.removeItem('token');
}
// Reset the websocket.
client.resetWebsocket();
dispatch({type: actions.LOGOUT});
});
};
//==============================================================================
// AUTH TOKEN
//==============================================================================
export const handleAuthToken = (token) => (dispatch, _, {storage}) => {
if (storage) {
storage.setItem('exp', jwtDecode(token).exp);
storage.setItem('token', token);
}
dispatch({type: 'HANDLE_AUTH_TOKEN'});
};
+6 -7
View File
@@ -14,13 +14,12 @@ import {
HIDE_REJECT_USERNAME_DIALOG
} from '../constants/community';
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
export const fetchAccounts = (query = {}) => (dispatch) => {
export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => {
dispatch(requestFetchAccounts());
coralApi(`/users?${qs.stringify(query)}`)
rest(`/users?${qs.stringify(query)}`)
.then(({result, page, count, limit, totalPages}) =>{
dispatch({
type: FETCH_COMMENTERS_SUCCESS,
@@ -51,15 +50,15 @@ export const newPage = () => ({
type: COMMENTERS_NEW_PAGE
});
export const setRole = (id, role) => (dispatch) => {
return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}})
export const setRole = (id, role) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/role`, {method: 'POST', body: {role}})
.then(() => {
return dispatch({type: SET_ROLE, id, role});
});
};
export const setCommenterStatus = (id, status) => (dispatch) => {
return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}})
export const setCommenterStatus = (id, status) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/status`, {method: 'POST', body: {status}})
.then(() => {
return dispatch({type: SET_COMMENTER_STATUS, id, status});
});
+4 -5
View File
@@ -1,4 +1,3 @@
import coralApi from 'coral-framework/helpers/request';
import * as actions from '../constants/install';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
@@ -106,10 +105,10 @@ export const submitUser = () => (dispatch, getState) => {
});
};
export const finishInstall = () => (dispatch, getState) => {
export const finishInstall = () => (dispatch, getState, {rest}) => {
const data = getState().install.data;
dispatch(installRequest());
return coralApi('/setup', {method: 'POST', body: data})
return rest('/setup', {method: 'POST', body: data})
.then(() => {
dispatch(installSuccess());
dispatch(nextStep());
@@ -129,9 +128,9 @@ const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST});
const checkInstallSuccess = (installed) => ({type: actions.CHECK_INSTALL_SUCCESS, installed});
const checkInstallFailure = (error) => ({type: actions.CHECK_INSTALL_FAILURE, error});
export const checkInstall = (next) => (dispatch) => {
export const checkInstall = (next) => (dispatch, _, {rest}) => {
dispatch(checkInstallRequest());
coralApi('/setup')
rest('/setup')
.then(({installed}) => {
dispatch(checkInstallSuccess(installed));
if (installed) {
+5 -3
View File
@@ -4,15 +4,17 @@ export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open});
export const singleView = () => ({type: actions.SINGLE_VIEW});
// hide shortcuts note
export const hideShortcutsNote = () => {
export const hideShortcutsNote = () => (dispatch, _, {storage}) => {
try {
window.localStorage.setItem('coral:shortcutsNote', 'hide');
if (storage) {
storage.setItem('coral:shortcutsNote', 'hide');
}
} catch (e) {
// above will fail in Safari private mode
}
return {type: actions.HIDE_SHORTCUTS_NOTE};
dispatch({type: actions.HIDE_SHORTCUTS_NOTE});
};
export const setSortOrder = (order) => ({
+4 -5
View File
@@ -1,4 +1,3 @@
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
export const SETTINGS_LOADING = 'SETTINGS_LOADING';
@@ -14,9 +13,9 @@ export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED';
export const fetchSettings = () => (dispatch) => {
export const fetchSettings = () => (dispatch, _, {rest}) => {
dispatch({type: SETTINGS_LOADING});
coralApi('/settings')
rest('/settings')
.then((settings) => {
dispatch({type: SETTINGS_RECEIVED, settings});
})
@@ -41,13 +40,13 @@ export const updateDomainlist = (listName, list) => {
return {type: DOMAINLIST_UPDATED, listName, list};
};
export const saveSettingsToServer = () => (dispatch, getState) => {
export const saveSettingsToServer = () => (dispatch, getState, {rest}) => {
let settings = getState().settings;
if (settings.charCount) {
settings.charCount = parseInt(settings.charCount);
}
dispatch({type: SAVE_SETTINGS_LOADING});
coralApi('/settings', {method: 'PUT', body: settings})
rest('/settings', {method: 'PUT', body: settings})
.then(() => {
dispatch({type: SAVE_SETTINGS_SUCCESS, settings});
})
+6 -7
View File
@@ -1,4 +1,3 @@
import coralApi from '../../../coral-framework/helpers/request';
import * as userTypes from '../constants/users';
import t from 'coral-framework/services/i18n';
@@ -7,9 +6,9 @@ import t from 'coral-framework/services/i18n';
*/
// change status of a user
export const userStatusUpdate = (status, userId, commentId) => {
return (dispatch) => {
return (dispatch, _, {rest}) => {
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
return rest(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
.catch((error) => {
console.error(error);
@@ -21,8 +20,8 @@ export const userStatusUpdate = (status, userId, commentId) => {
// change status of a user
export const sendNotificationEmail = (userId, subject, body) => {
return (dispatch) => {
return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
return (dispatch, _, {rest}) => {
return rest(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
@@ -33,8 +32,8 @@ export const sendNotificationEmail = (userId, subject, body) => {
// let a user edit their username
export const enableUsernameEdit = (userId) => {
return (dispatch) => {
return coralApi(`/users/${userId}/username-enable`, {method: 'POST'})
return (dispatch, _, {rest}) => {
return rest(`/users/${userId}/username-enable`, {method: 'POST'})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
+1 -2
View File
@@ -3,12 +3,11 @@ import {connect} from 'react-redux';
import Layout from '../components/ui/Layout';
import {fetchConfig} from '../actions/config';
import AdminLogin from '../components/AdminLogin';
import {logout} from 'coral-framework/actions/auth';
import {FullLoading} from '../components/FullLoading';
import BanUserDialog from './BanUserDialog';
import SuspendUserDialog from './SuspendUserDialog';
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
import {checkLogin, handleLogin, requestPasswordReset, logout} from '../actions/auth';
import {can} from 'coral-framework/services/perms';
import UserDetail from 'coral-admin/src/containers/UserDetail';
@@ -5,22 +5,24 @@ import SuspendUserDialog from '../components/SuspendUserDialog';
import {hideSuspendUserDialog} from '../actions/suspendUserDialog';
import {withSetCommentStatus, withSuspendUser} from 'coral-framework/graphql/mutations';
import {compose, gql} from 'react-apollo';
import * as notification from 'coral-admin/src/services/notification';
import t, {timeago} from 'coral-framework/services/i18n';
import withQuery from 'coral-framework/hocs/withQuery';
import {getErrorMessages} from 'coral-framework/utils';
import get from 'lodash/get';
import {notify} from 'coral-framework/actions/notification';
class SuspendUserDialogContainer extends Component {
suspendUser = async ({message, until}) => {
const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser} = this.props;
const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser, notify} = this.props;
hideSuspendUserDialog();
try {
const result = await suspendUser({id: userId, message, until});
if (result.data.suspendUser.errors) {
throw result.data.suspendUser.errors;
}
notification.success(
notify(
'success',
t('suspenduser.notify_suspend_until', username, timeago(until)),
);
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
@@ -33,7 +35,7 @@ class SuspendUserDialogContainer extends Component {
}
}
catch(err) {
notification.showMutationErrors(err);
notify('error', getErrorMessages(err));
}
};
@@ -69,6 +71,7 @@ const mapStateToProps = ({suspendUserDialog: {open, userId, username, commentId,
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
hideSuspendUserDialog,
notify,
}, dispatch),
});
@@ -124,7 +124,7 @@ class UserDetailContainer extends React.Component {
}
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
}
+1 -4
View File
@@ -1,6 +1,4 @@
import {add} from 'coral-framework/services/graphqlRegistry';
const extension = {
export default {
mutations: {
SetUserStatus: () => ({
refetchQueries: ['CoralAdmin_Community'],
@@ -11,4 +9,3 @@ const extension = {
},
};
add(extension);
+22 -20
View File
@@ -1,32 +1,34 @@
import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import smoothscroll from 'smoothscroll-polyfill';
import EventEmitter from 'eventemitter2';
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
import {getClient} from 'coral-framework/services/client';
import store from './services/store';
import TalkProvider from 'coral-framework/components/TalkProvider';
import {createContext} from 'coral-framework/services/bootstrap';
import reducers from './reducers';
import App from './components/App';
import 'react-mdl/extra/material.js';
import './graphql';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import graphqlExtension from './graphql';
import pluginsConfig from 'pluginsConfig';
import {toast} from 'react-toastify';
import {createNotificationService} from './services/notification';
import {hideShortcutsNote} from './actions/moderation';
const eventEmitter = new EventEmitter();
function hidrateStore({store, storage}) {
if (storage && storage.getItem('coral:shortcutsNote') === 'hide') {
store.dispatch(hideShortcutsNote());
}
}
// TODO: pass redux actions through the emitter.
loadPluginsTranslations();
injectPluginsReducers();
smoothscroll.polyfill();
const notification = createNotificationService(toast);
const context = createContext({reducers, graphqlExtension, pluginsConfig, notification});
// hidrate Store with external data.
hidrateStore(context);
render(
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
</EventEmitterProvider>
<TalkProvider {...context}>
<App />
</TalkProvider>
, document.querySelector('#root')
);
@@ -5,14 +5,17 @@ const initialState = {
modalOpen: false,
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
shortcutsNoteVisible: 'show',
sortOrder: 'DESC',
};
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
return initialState;
return {
...initialState,
shortcutsNoteVisible: state.shortcutsNoteVisible,
};
case actions.TOGGLE_MODAL:
return {
...state,
@@ -4,7 +4,7 @@ import t from 'coral-framework/services/i18n';
import styles from './Configure.css';
import {Checkbox, Textfield} from 'react-mdl';
import {Card, Icon, TextArea} from 'coral-ui';
import MarkdownEditor from 'coral-admin/src/components/MarkdownEditor';
import MarkdownEditor from 'coral-framework/components/MarkdownEditor';
const TIMESTAMPS = {
weeks: 60 * 60 * 24 * 7,
@@ -5,17 +5,24 @@ import {Icon} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const refreshIntervalSeconds = 60 * 5;
// TODO: refactor out storage code into redux.
class CountdownTimer extends React.Component {
static contextTypes = {
storage: PropTypes.object,
};
static propTypes = {
handleTimeout: PropTypes.func.isRequired
}
constructor (props) {
super(props);
constructor (props, context) {
super(props, context);
const {storage} = context;
try {
if (window.localStorage.getItem('coral:dashboardNote') === null) {
window.localStorage.setItem('coral:dashboardNote', 'show');
if (storage && storage.getItem('coral:dashboardNote') === null) {
storage.setItem('coral:dashboardNote', 'show');
}
} catch (e) {
@@ -24,7 +31,7 @@ class CountdownTimer extends React.Component {
this.state = {
secondsUntilRefresh: refreshIntervalSeconds,
dashboardNote: window.localStorage.getItem('coral:dashboardNote') || 'show'
dashboardNote: (storage && storage.getItem('coral:dashboardNote')) || 'show'
};
}
@@ -54,8 +61,11 @@ class CountdownTimer extends React.Component {
}
dismissNote = () => {
const {storage} = this.context;
try {
window.localStorage.setItem('coral:dashboardNote', 'hide');
if (storage) {
storage.setItem('coral:dashboardNote', 'hide');
}
} catch (e) {
// when setItem fails in Safari Private mode
@@ -64,7 +74,8 @@ class CountdownTimer extends React.Component {
}
render () {
const hideReloadNote = window.localStorage.getItem('coral:dashboardNote') === 'hide' ||
const {storage} = this.context;
const hideReloadNote = (storage && storage.getItem('coral:dashboardNote') === 'hide') ||
this.state.dashboardNote === 'hide'; // for Safari Incognito
return (
<p
@@ -22,10 +22,10 @@ class DashboardContainer extends React.Component {
export const witDashboardQuery = withQuery(gql`
query CoralAdmin_Dashboard($from: Date!, $to: Date!) {
assetsByFlag: assetMetrics(from: $from, to: $to, sort: FLAG) {
assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) {
...CoralAdmin_Metrics
}
assetsByActivity: assetMetrics(from: $from, to: $to, sort: ACTIVITY) {
assetsByActivity: assetMetrics(from: $from, to: $to, sortBy: ACTIVITY) {
...CoralAdmin_Metrics
}
}
@@ -36,8 +36,8 @@ const ModerationMenu = ({
label="Sort"
value={sort}
onChange={(sort) => selectSort(sort)}>
<Option value={'REVERSE_CHRONOLOGICAL'}>{t('modqueue.newest_first')}</Option>
<Option value={'CHRONOLOGICAL'}>{t('modqueue.oldest_first')}</Option>
<Option value={'DESC'}>{t('modqueue.newest_first')}</Option>
<Option value={'ASC'}>{t('modqueue.oldest_first')}</Option>
</SelectField>
</div>
</div>
@@ -26,25 +26,27 @@ import {
storySearchChange,
clearState
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {notify} from 'coral-framework/actions/notification';
import {Spinner} from 'coral-ui';
import Moderation from '../components/Moderation';
import Comment from './Comment';
import queueConfig from '../queueConfig';
import baseQueueConfig from '../queueConfig';
function prepareNotificationText(text) {
return truncate(text, {length: 50}).replace('\n', ' ');
}
function getAssetId(props) {
if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) {
if (props.params.tabOrId && !(props.params.tabOrId in props.queueConfig)) {
return props.params.tabOrId;
}
return props.params.id || null;
}
function getTab(props) {
if (props.params.tabOrId && props.params.tabOrId in queueConfig) {
if (props.params.tabOrId && props.params.tabOrId in props.queueConfig) {
return props.params.tabOrId;
}
return props.params.tab || null;
@@ -53,8 +55,15 @@ function getTab(props) {
class ModerationContainer extends Component {
subscriptions = [];
handleCommentChange = (root, comment, notify) => {
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab);
handleCommentChange = (root, comment, notifyText) => {
return handleCommentChange(
root,
comment,
this.props.data.variables.sortOrder,
() => notifyText && this.props.notify('info', notifyText),
this.props.queueConfig,
this.activeTab
);
};
get activeTab() {
@@ -78,10 +87,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 notify = this.props.auth.user.id === user.id
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -90,10 +99,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 notify = this.props.auth.user.id === user.id
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -101,8 +110,8 @@ class ModerationContainer extends Component {
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
const notify = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
const notifyText = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -111,8 +120,8 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
const user = comment.actions[comment.actions.length - 1].user;
const notify = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notifyText);
},
});
@@ -159,10 +168,10 @@ class ModerationContainer extends Component {
const variables = {
limit: 10,
cursor: this.props.root[tab].endCursor,
sort: this.props.data.variables.sort,
sortOrder: this.props.data.variables.sortOrder,
asset_id: this.props.data.variables.asset_id,
statuses: queueConfig[tab].statuses,
action_type: queueConfig[tab].action_type,
statuses: this.props.queueConfig[tab].statuses,
action_type: this.props.queueConfig[tab].action_type,
};
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -206,7 +215,7 @@ class ModerationContainer extends Component {
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, queueConfig);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
if (premodEnabled) {
delete currentQueueConfig.new;
} else {
@@ -279,8 +288,8 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql`
`;
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) {
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
@@ -304,15 +313,15 @@ const commentConnectionFragment = gql`
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
const withModQueueQuery = withQuery(({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!) {
${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
sortOrder: $sortOrder
}) {
...CoralAdmin_Moderation_CommentConnection
}
@@ -345,14 +354,14 @@ const withModQueueQuery = withQuery(gql`
return {
variables: {
asset_id: id,
sort: props.moderation.sortOrder,
sortOrder: props.moderation.sortOrder,
allAssets: id === null
}
};
},
});
const withQueueCountPolling = withQuery(gql`
const withQueueCountPolling = withQuery(({queueConfig}) => gql`
query CoralAdmin_ModerationCountPoll($asset_id: ID) {
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
@@ -393,11 +402,13 @@ const mapDispatchToProps = (dispatch) => ({
viewUserDetail,
setSortOrder,
storySearchChange,
clearState
clearState,
notify,
}, dispatch),
});
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withQueueCountPolling,
@@ -1,5 +1,4 @@
import update from 'immutability-helper';
import * as notification from 'coral-admin/src/services/notification';
const limit = 10;
@@ -29,29 +28,29 @@ function removeCommentFromQueue(root, queue, id) {
});
}
function shouldCommentBeAdded(root, queue, comment, sort) {
function shouldCommentBeAdded(root, queue, comment, sortOrder) {
if (root[`${queue}Count`] < limit) {
// Adding all comments until first limit has reached.
return true;
}
const cursor = new Date(root[queue].endCursor);
return sort === 'CHRONOLOGICAL'
return sortOrder === 'ASC'
? new Date(comment.created_at) <= cursor
: new Date(comment.created_at) >= cursor;
}
function addCommentToQueue(root, queue, comment, sort) {
function addCommentToQueue(root, queue, comment, sortOrder) {
if (queueHasComment(root, queue, comment.id)) {
return root;
}
const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending;
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
};
if (shouldCommentBeAdded(root, queue, comment, sort)) {
if (shouldCommentBeAdded(root, queue, comment, sortOrder)) {
const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
changes[queue] = {
nodes: {$set: nodes},
@@ -91,16 +90,13 @@ function getCommentQueues(comment, queueConfig) {
* Assimilate comment changes into current store.
* @param {Object} root current state of the store
* @param {Object} comment comment that was changed
* @param {string} sort current sort order of the queues
* @param {Object} [notify] show know notifications if set
* @param {string} notify.activeQueue current active queue
* @param {string} notify.text notification text to show
* @param {bool} notify.anyQueue if true show the notification when the comment is shown
* @param {string} sortOrder current sort order of the queues
* @param {string} notify callback to show notification
* 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, queueConfig, activeQueue) {
export function handleCommentChange(root, comment, sortOrder, notify, queueConfig, activeQueue) {
let next = root;
const nextQueues = getCommentQueues(comment, queueConfig);
@@ -110,22 +106,22 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac
if (notificationShown) {
return;
}
notification.info(notify);
notify();
notificationShown = true;
};
Object.keys(queueConfig).forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
showNotificationOnce(comment);
next = addCommentToQueue(next, queue, comment, sortOrder);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) {
showNotificationOnce();
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && activeQueue === queue) {
showNotificationOnce(comment);
showNotificationOnce();
}
}
@@ -134,7 +130,7 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac
&& queueHasComment(next, queue, comment.id)
&& activeQueue === queue
) {
showNotificationOnce(comment);
showNotificationOnce();
}
});
return next;
@@ -0,0 +1,26 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
/**
* WithQueueConfig takes a `queueConfig` parameter that is
* passed down as a prop enriched with queue config data from plugins.
*/
export default (queueConfig) => hoistStatics((WrappedComponent) => {
class WithQueueConfig extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
pluginsConfig = this.context.plugins.getModQueueConfigs();
render() {
return <WrappedComponent
{...this.props}
queueConfig={{...queueConfig, ...this.pluginsConfig}}
/>;
}
}
return WithQueueConfig;
});
@@ -1,5 +1,4 @@
import t from 'coral-framework/services/i18n';
import {getModQueueConfigs} from 'coral-framework/helpers/plugins';
export default {
premod: {
@@ -33,5 +32,4 @@ export default {
icon: 'question_answer',
name: t('modqueue.all'),
},
...getModQueueConfigs(),
};
+17 -25
View File
@@ -1,26 +1,18 @@
import t from 'coral-framework/services/i18n';
import {toast} from 'react-toastify';
export function success(msg) {
return toast(msg, {type: 'success'});
}
export function error(msg) {
return toast(msg, {type: 'error'});
}
export function info(msg) {
return toast(msg, {type: 'info'});
}
export function showMutationErrors(error) {
console.error(error);
if (error.errors) {
error.errors.forEach((err) => {
toast(
err.translation_key ? t(`error.${err.translation_key}`) : err,
{type: 'error'}
);
});
}
/**
* createNotificationService returns a notification services based on toast.
* @param {Object} toast
* @return {Object} notification service
*/
export function createNotificationService(toast) {
return {
success(msg) {
toast(msg, {type: 'success'});
},
error(msg) {
toast(msg, {type: 'error'});
},
info(msg) {
toast(msg, {type: 'info'});
},
};
}
-31
View File
@@ -1,31 +0,0 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {getClient} from 'coral-framework/services/client';
const middlewares = [
applyMiddleware(getClient().middleware()),
applyMiddleware(thunk)
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: getClient().reducer()
};
const store = createStore(
combineReducers(coralReducers),
{},
compose(...middlewares)
);
store.coralReducers = coralReducers;
window.coralStore = store;
export default store;
@@ -12,10 +12,6 @@
padding: 0;
}
.wrapper ul ul {
padding-left: 20px
}
.checkbox {
vertical-align: top;
margin: 12px 12px 12px 0;
@@ -36,4 +32,4 @@
.hidden {
display: none;
}
}
@@ -1,5 +1,6 @@
import React from 'react';
import {Button, Checkbox, TextField} from 'coral-ui';
import {Button, Checkbox} from 'coral-ui';
import QuestionBoxBuilder from './QuestionBoxBuilder';
import styles from './ConfigureCommentStream.css';
@@ -55,17 +56,14 @@ export default ({handleChange, handleApply, changed, ...props}) => (
title: t('configure.enable_questionbox'),
description: t('configure.enable_questionbox_description')
}} />
<div className={`${props.questionBoxEnable ? null : styles.hidden}`} >
<TextField
id="qboxcontent"
onChange={handleChange}
rows={3}
value={props.questionBoxContent}
label={t('configure.include_question_here')}
/>
</div>
{
props.questionBoxEnable && <QuestionBoxBuilder
questionBoxIcon={props.questionBoxIcon}
questionBoxContent={props.questionBoxContent}
handleChange={handleChange}
/>
}
</li>
</ul>
</div>
</form>
@@ -0,0 +1,27 @@
.iconBubble{
position: absolute;
top: 8px;
left: 10px;
color: #9E9E9E;
font-size: 24px;
z-index: 0;
}
.iconPerson{
z-index: 2;
top: 12px;
left: 12px;
position: absolute;
font-size: 33px;
color: #262626;
}
.qbIconContainer {
position: relative;
border: 0;
color: white;
display: inline-block;
padding: 5px 20px;
vertical-align: middle;
width: 10px;
}
@@ -0,0 +1,13 @@
import React from 'react';
import cn from 'classnames';
import styles from './DefaultIcon.css';
import {Icon} from 'coral-ui';
const DefaultIcon = ({className}) => (
<div className={cn(styles.qbIconContainer, className)}>
<Icon name="chat_bubble" className={cn(styles.iconBubble)} />
<Icon name="person" className={cn(styles.iconPerson)} />
</div>
);
export default DefaultIcon;
@@ -0,0 +1,45 @@
.qbBuilder {
margin-left: 50px;
}
.qbItemIconList {
padding: 0;
margin: 10px 0;
}
.qbItemIcon {
background: #F0F0F0;
width: 45px;
height: 45px;
font-size: 24px;
text-align: center;
line-height: 45px;
color: #252525;
border-radius: 3px;
display: inline-block;
overflow: hidden;
margin-right: 10px;
position: relative;
border: solid 2px #F0F0F0;
transition: border 0.3s cubic-bezier(.4,0,.2,1);
}
.qbItemIcon:hover {
cursor: pointer;
}
.qbItemIconActive {
border: solid 2px #00796B;
}
.defaultIcon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.qb {
margin: 10px 0;
}
@@ -0,0 +1,98 @@
import React from 'react';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import {Icon, Spinner} from 'coral-ui';
import DefaultIcon from './DefaultIcon';
import cn from 'classnames';
import styles from './QuestionBoxBuilder.css';
class QuestionBoxBuilder extends React.Component {
constructor() {
super();
this.state = {
loading: true
};
}
componentWillMount() {
this.loadEditor();
}
async loadEditor() {
const MarkdownEditor = await import('coral-framework/components/MarkdownEditor');
return this.setState({
loading : false,
MarkdownEditor
});
}
render() {
const {handleChange, questionBoxIcon, questionBoxContent} = this.props;
const {loading, MarkdownEditor} = this.state;
if (loading) {
return <Spinner/>;
}
return (
<div className={styles.qbBuilder}>
<h4>Include an Icon</h4>
<ul className={styles.qbItemIconList}>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'default'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="default" >
<DefaultIcon className={styles.defaultIcon} />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'forum'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="forum" >
<Icon name="forum" />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'build'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="build" >
<Icon name="build" />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'format_quote'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="format_quote" >
<Icon name="format_quote" />
</li>
</ul>
<QuestionBox
className={styles.qb}
enable={true}
icon={questionBoxIcon}
content={questionBoxContent}
/>
<MarkdownEditor
value={questionBoxContent}
onChange={(value) => handleChange({}, {questionBoxContent: value})}
/>
</div>
);
}
}
export default QuestionBoxBuilder;
@@ -2,7 +2,7 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {compose} from 'react-apollo';
import {updateOpenStatus, updateConfiguration} from 'coral-framework/actions/asset';
import {updateOpenStatus, updateConfiguration} from 'coral-embed-stream/src/actions/asset';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
@@ -27,10 +27,9 @@ class ConfigureStreamContainer extends Component {
handleApply (e) {
e.preventDefault();
const {elements} = e.target;
const {questionBoxIcon, questionBoxContent} = this.state.dirtySettings;
const premod = elements.premod.checked;
const questionBoxEnable = elements.qboxenable.checked;
const questionBoxContent = elements.qboxcontent.value;
const premodLinksEnable = elements.plinksenable.checked;
const {changed} = this.state;
@@ -38,6 +37,7 @@ class ConfigureStreamContainer extends Component {
moderation: premod ? 'PRE' : 'POST',
questionBoxEnable,
questionBoxContent,
questionBoxIcon,
premodLinksEnable
};
@@ -51,14 +51,18 @@ class ConfigureStreamContainer extends Component {
}
}
handleChange (e) {
const changes = {};
handleChange (e, newChanges) {
let changes = {};
if (changes) {
changes = {...newChanges};
}
if (e.target && e.target.id === 'qboxenable') {
changes.questionBoxEnable = e.target.checked;
}
if (e.target && e.target.id === 'qboxcontent') {
changes.questionBoxContent = e.target.value;
if (e.currentTarget && e.currentTarget.id === 'qboxicon') {
changes.questionBoxIcon = e.currentTarget.dataset.icon;
}
if (e.target && e.target.id === 'plinksenable') {
changes.premodLinksEnable = e.target.value;
@@ -105,6 +109,7 @@ class ConfigureStreamContainer extends Component {
changed={this.state.changed}
premodLinksEnable={dirtySettings.premodLinksEnable}
premod={premod}
questionBoxIcon={dirtySettings.questionBoxIcon}
questionBoxEnable={dirtySettings.questionBoxEnable}
questionBoxContent={dirtySettings.questionBoxContent}
/>
+10 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {Router, Route} from 'react-router';
import {history} from 'coral-framework/helpers/router';
import PropTypes from 'prop-types';
import Embed from './containers/Embed';
import {LoginContainer} from 'coral-sign-in/containers/LoginContainer';
@@ -12,6 +12,14 @@ const routes = (
</div>
);
const AppRouter = () => <Router history={history} routes={routes} />;
class AppRouter extends React.Component {
static contextTypes = {
history: PropTypes.object,
};
render() {
return <Router history={this.context.history} routes={routes} />;
}
}
export default AppRouter;
@@ -1,6 +1,5 @@
import * as actions from '../constants/asset';
import coralApi from '../helpers/request';
import {addNotification} from '../actions/notification';
import {notify} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
@@ -12,12 +11,12 @@ const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_R
const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings});
const updateAssetSettingsFailure = (error) => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE, error});
export const updateConfiguration = (newConfig) => (dispatch, getState) => {
export const updateConfiguration = (newConfig) => (dispatch, getState, {rest}) => {
const assetId = getState().asset.id;
dispatch(updateAssetSettingsRequest());
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
rest(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
.then(() => {
dispatch(addNotification('success', t('framework.success_update_settings')));
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(updateAssetSettingsSuccess(newConfig));
})
.catch((error) => {
@@ -26,12 +25,12 @@ export const updateConfiguration = (newConfig) => (dispatch, getState) => {
});
};
export const updateOpenStream = (closedBody) => (dispatch, getState) => {
export const updateOpenStream = (closedBody) => (dispatch, getState, {rest}) => {
const assetId = getState().asset.id;
dispatch(fetchAssetRequest());
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
rest(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
.then(() => {
dispatch(addNotification('success', t('framework.success_update_settings')));
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(fetchAssetSuccess(closedBody));
})
.catch((error) => {
@@ -1,12 +1,8 @@
import jwtDecode from 'jwt-decode';
import bowser from 'bowser';
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 {notify} from 'coral-framework/actions/notification';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
export const showSignInDialog = () => ({
@@ -59,9 +55,9 @@ export const updateUsername = ({username}) => ({
username
});
export const createUsername = (userId, formData) => (dispatch) => {
export const createUsername = (userId, formData) => (dispatch, _, {rest}) => {
dispatch(createUsernameRequest());
coralApi('/account/username', {method: 'PUT', body: formData})
rest('/account/username', {method: 'PUT', body: formData})
.then(() => {
dispatch(createUsernameSuccess());
dispatch(hideCreateUsernameDialog());
@@ -111,9 +107,11 @@ const signInFailure = (error) => ({
// AUTH TOKEN
//==============================================================================
export const handleAuthToken = (token) => (dispatch) => {
Storage.setItem('exp', jwtDecode(token).exp);
Storage.setItem('token', token);
export const handleAuthToken = (token) => (dispatch, _, {storage}) => {
if (storage) {
storage.setItem('exp', jwtDecode(token).exp);
storage.setItem('token', token);
}
dispatch({type: 'HANDLE_AUTH_TOKEN'});
};
@@ -123,10 +121,10 @@ export const handleAuthToken = (token) => (dispatch) => {
//==============================================================================
export const fetchSignIn = (formData) => {
return (dispatch) => {
return (dispatch, _, {rest}) => {
dispatch(signInRequest());
return coralApi('/auth/local', {method: 'POST', body: formData})
return rest('/auth/local', {method: 'POST', body: formData})
.then(({token}) => {
if (!bowser.safari && !bowser.ios) {
dispatch(handleAuthToken(token));
@@ -171,10 +169,10 @@ const signInFacebookFailure = (error) => ({
error
});
export const fetchSignInFacebook = () => (dispatch) => {
export const fetchSignInFacebook = () => (dispatch, _, {rest}) => {
dispatch(signInFacebookRequest());
window.open(
`${base}/auth/facebook`,
`${rest.uri}/auth/facebook`,
'Continue with Facebook',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
@@ -188,10 +186,10 @@ const signUpFacebookRequest = () => ({
type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST
});
export const fetchSignUpFacebook = () => (dispatch) => {
export const fetchSignUpFacebook = () => (dispatch, _, {rest}) => {
dispatch(signUpFacebookRequest());
window.open(
`${base}/auth/facebook`,
`${rest.uri}/auth/facebook`,
'Continue with Facebook',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
@@ -220,11 +218,11 @@ const signUpRequest = () => ({type: actions.FETCH_SIGNUP_REQUEST});
const signUpSuccess = (user) => ({type: actions.FETCH_SIGNUP_SUCCESS, user});
const signUpFailure = (error) => ({type: actions.FETCH_SIGNUP_FAILURE, error});
export const fetchSignUp = (formData) => (dispatch, getState) => {
export const fetchSignUp = (formData) => (dispatch, getState, {rest}) => {
const redirectUri = getState().auth.redirectUri;
dispatch(signUpRequest());
coralApi('/users', {
rest('/users', {
method: 'POST',
body: formData,
headers: {'X-Pym-Url': redirectUri}
@@ -256,10 +254,10 @@ const forgotPasswordFailure = (error) => ({
error,
});
export const fetchForgotPassword = (email) => (dispatch, getState) => {
export const fetchForgotPassword = (email) => (dispatch, getState, {rest}) => {
dispatch(forgotPasswordRequest(email));
const redirectUri = getState().auth.redirectUri;
coralApi('/account/password/reset', {
rest('/account/password/reset', {
method: 'POST',
body: {email, loc: redirectUri}
})
@@ -275,16 +273,18 @@ export const fetchForgotPassword = (email) => (dispatch, getState) => {
// LOGOUT
//==============================================================================
export const logout = () => (dispatch) => {
return coralApi('/auth', {method: 'DELETE'}).then(() => {
Storage.removeItem('token');
export const logout = () => async (dispatch, _, {rest, client, pym, storage}) => {
await rest('/auth', {method: 'DELETE'});
// Reset the websocket.
resetWebsocket();
if (storage) {
storage.removeItem('token');
}
dispatch({type: actions.LOGOUT});
pym.sendMessage('coral-auth-changed');
});
// Reset the websocket.
client.resetWebsocket();
dispatch({type: actions.LOGOUT});
pym.sendMessage('coral-auth-changed');
};
//==============================================================================
@@ -300,17 +300,19 @@ const checkLoginSuccess = (user, isAdmin) => ({
isAdmin
});
export const checkLogin = () => (dispatch) => {
export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
dispatch(checkLoginRequest());
coralApi('/auth')
rest('/auth')
.then((result) => {
if (!result.user) {
Storage.removeItem('token');
if (storage) {
storage.removeItem('token');
}
throw new Error('Not logged in');
}
// Reset the websocket.
resetWebsocket();
client.resetWebsocket();
dispatch(checkLoginSuccess(result.user));
pym.sendMessage('coral-auth-changed', JSON.stringify(result.user));
@@ -322,10 +324,10 @@ export const checkLogin = () => (dispatch) => {
})
.catch((error) => {
console.error(error);
if (error.status && error.status === 401) {
if (error.status && error.status === 401 && storage) {
// Unauthorized.
Storage.removeItem('token');
storage.removeItem('token');
}
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(checkLoginFailure(errorMessage));
@@ -351,10 +353,10 @@ const verifyEmailFailure = () => ({
type: actions.VERIFY_EMAIL_FAILURE
});
export const requestConfirmEmail = (email) => (dispatch, getState) => {
export const requestConfirmEmail = (email) => (dispatch, getState, {rest}) => {
const redirectUri = getState().auth.redirectUri;
dispatch(verifyEmailRequest());
return coralApi('/users/resend-verify', {
return rest('/users/resend-verify', {
method: 'POST',
body: {email},
headers: {'X-Pym-Url': redirectUri}
@@ -387,11 +389,11 @@ export const setRedirectUri = (uri) => ({
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}})
export const editName = (username) => (dispatch, _, {rest}) => {
return rest('/account/username', {method: 'PUT', body: {username}})
.then(() => {
dispatch(editUsernameSuccess());
dispatch(addNotification('success', t('framework.success_name_update')));
dispatch(notify('success', t('framework.success_name_update')));
})
.catch((error) => {
console.error(error);
@@ -1,11 +1,12 @@
import pym from 'coral-framework/services/pym';
import * as actions from '../constants/stream';
import {buildUrl} from 'coral-framework/utils/url';
import queryString from 'query-string';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
export const viewAllComments = () => {
export const setSort = ({sortOrder, sortBy}) => ({type: actions.SET_SORT, sortOrder, sortBy});
export const viewAllComments = () => (dispatch, _, {pym}) => {
const search = queryString.stringify({
...queryString.parse(location.search),
@@ -24,10 +25,10 @@ export const viewAllComments = () => {
pym.sendMessage('coral-view-all-comments');
} catch (e) { /* not sure if we're worried about old browsers */ }
return {type: actions.VIEW_ALL_COMMENTS};
dispatch({type: actions.VIEW_ALL_COMMENTS});
};
export const viewComment = (id) => {
export const viewComment = (id) => (dispatch, _, {pym}) => {
const search = queryString.stringify({
...queryString.parse(location.search),
@@ -46,7 +47,7 @@ export const viewComment = (id) => {
pym.sendMessage('coral-view-comment', id);
} catch (e) { /* not sure if we're worried about old browsers */ }
return {type: actions.VIEW_COMMENT, id};
dispatch({type: actions.VIEW_COMMENT, id});
};
export const addCommentClassName = (className) => ({
@@ -87,7 +87,7 @@ class AllCommentsPane extends React.Component {
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
forEachError(error, ({msg}) => {this.props.notify('error', msg);});
});
}
@@ -129,7 +129,7 @@ class AllCommentsPane extends React.Component {
ignoreUser,
setActiveReplyBox,
activeReplyBox,
addNotification,
notify,
disableReply,
postComment,
asset,
@@ -166,7 +166,7 @@ class AllCommentsPane extends React.Component {
disableReply={disableReply}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
notify={notify}
depth={0}
postComment={postComment}
asset={asset}
@@ -152,7 +152,7 @@ export default class Comment extends React.Component {
deleteAction: PropTypes.func.isRequired,
parentId: PropTypes.string,
highlighted: PropTypes.string,
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
liveUpdates: PropTypes.bool,
@@ -205,7 +205,7 @@ export default class Comment extends React.Component {
onClickEdit (e) {
e.preventDefault();
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({isEditing: true});
@@ -235,7 +235,7 @@ export default class Comment extends React.Component {
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
forEachError(error, ({msg}) => {this.props.notify('error', msg);});
});
emit('ui.Comment.showMoreReplies', {id});
return;
@@ -252,7 +252,7 @@ export default class Comment extends React.Component {
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.setActiveReplyBox(this.props.comment.id);
} else {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
this.props.notify('error', t('error.NOT_AUTHORIZED'));
}
return;
}
@@ -331,7 +331,7 @@ export default class Comment extends React.Component {
deleteAction,
disableReply,
maxCharCount,
addNotification,
notify,
charCountEnable,
showSignInDialog,
liveUpdates,
@@ -482,7 +482,7 @@ export default class Comment extends React.Component {
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
notify={notify} />
</span>
}
{ !isActive &&
@@ -494,7 +494,7 @@ export default class Comment extends React.Component {
this.state.isEditing
? <EditableCommentContent
editComment={this.editComment}
addNotification={addNotification}
notify={notify}
comment={comment}
currentUser={currentUser}
charCountEnable={charCountEnable}
@@ -546,7 +546,7 @@ export default class Comment extends React.Component {
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
addNotification={addNotification}
notify={notify}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
@@ -567,7 +567,7 @@ export default class Comment extends React.Component {
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
addNotification={addNotification}
notify={notify}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
@@ -584,7 +584,7 @@ export default class Comment extends React.Component {
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
notify={notify}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
@@ -17,7 +17,7 @@ export class EditableCommentContent extends React.Component {
static propTypes = {
// show notification to the user (e.g. for errors)
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
// comment that is being edited
comment: PropTypes.shape({
@@ -74,26 +74,26 @@ export class EditableCommentContent extends React.Component {
handleSubmit = async () => {
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({loadingState: 'loading'});
const {editComment, addNotification, stopEditing} = this.props;
const {editComment, notify, stopEditing} = this.props;
if (typeof editComment !== 'function') {return;}
let response;
try {
response = await editComment({body: this.state.body});
this.setState({loadingState: 'success'});
const status = response.data.editComment.comment.status;
notifyForNewCommentStatus(this.props.addNotification, status);
notifyForNewCommentStatus(this.props.notify, status);
if (typeof stopEditing === 'function') {
stopEditing();
}
} catch (error) {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => addNotification('error', msg));
forEachError(error, ({msg}) => notify('error', msg));
}
}
+162 -110
View File
@@ -13,7 +13,7 @@ import t, {timeago} from 'coral-framework/services/i18n';
import CommentBox from 'talk-plugin-commentbox/CommentBox';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import {isCommentActive} from 'coral-framework/utils';
import {Button, Tab, TabCount, TabPane} from 'coral-ui';
import {Spinner, Button, Tab, TabCount, TabPane} from 'coral-ui';
import cn from 'classnames';
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
@@ -23,6 +23,8 @@ import StreamTabPanel from '../containers/StreamTabPanel';
import styles from './Stream.css';
const SpinnerWhenLoading = ({loading, children}) => loading ? <Spinner /> : <div>{children}</div>;
class Stream extends React.Component {
constructor(props) {
@@ -49,39 +51,30 @@ class Stream extends React.Component {
);
};
render() {
renderHighlightedComment() {
const {
data,
root,
activeReplyBox,
setActiveReplyBox,
appendItemArray,
commentClassNames,
root: {asset, asset: {comment, comments, totalCommentCount}},
root: {asset, asset: {comment}},
postComment,
addNotification,
notify,
editComment,
postFlag,
postDontAgree,
deleteAction,
showSignInDialog,
updateItem,
ignoreUser,
activeStreamTab,
setActiveStreamTab,
loadNewReplies,
loadMoreComments,
viewAllComments,
auth: {loggedIn, user},
editName,
auth: {user},
emit,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
let highlightedComment = comment && getTopLevelParent(comment);
if (highlightedComment) {
let topLevelComment = getTopLevelParent(comment);
if (topLevelComment) {
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
const isInactive = !isCommentActive(comment.status);
@@ -89,10 +82,152 @@ class Stream extends React.Component {
// the highlighted comment is not active and as such not in the replies, so we
// attach it to the right parent.
highlightedComment = attachCommentToParent(highlightedComment, comment);
topLevelComment = attachCommentToParent(topLevelComment, comment);
}
}
return (
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
<Comment
data={data}
root={root}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
depth={0}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
highlighted={comment.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={topLevelComment.id}
commentIsIgnored={this.commentIsIgnored}
comment={topLevelComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
liveUpdates={true}
/>
</div>
);
}
renderTabPanel() {
const {
data,
root,
activeReplyBox,
setActiveReplyBox,
commentClassNames,
root: {asset, asset: {comments, totalCommentCount}},
postComment,
notify,
editComment,
postFlag,
postDontAgree,
deleteAction,
showSignInDialog,
ignoreUser,
activeStreamTab,
setActiveStreamTab,
loadNewReplies,
loadMoreComments,
auth: {user},
emit,
sortOrder,
sortBy,
} = this.props;
const slotProps = {data};
const slotQueryData = {root, asset};
// `key` of `StreamTabPanel` depends on sorting so that we always reset
// the state when changing sorting.
return (
<div className={cn('talk-stream-tab-container', styles.tabContainer)}>
<div
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
>
<Slot
fill="streamFilter"
queryData={slotQueryData}
{...slotProps}
/>
</div>
<StreamTabPanel
key={`${sortBy}_${sortOrder}`}
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>
}
appendTabPanes={
<TabPane tabId={'all'} key='all'>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
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>
}
sub
/>
</div>
);
}
render() {
const {
data,
root,
appendItemArray,
root: {asset, asset: {comment: highlightedComment, comments}},
postComment,
notify,
updateItem,
viewAllComments,
auth: {loggedIn, user},
editName,
loading,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
const banned = user && user.status === 'BANNED';
const temporarilySuspended =
user &&
@@ -103,7 +238,7 @@ class Stream extends React.Component {
const slotProps = {data};
const slotQueryData = {root, asset};
if (!comment && !comments) {
if (!highlightedComment && !comments) {
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
return <StreamError />;
}
@@ -111,7 +246,7 @@ class Stream extends React.Component {
return (
<div id="stream" className={styles.root}>
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
{comment &&
{highlightedComment &&
<Button
cStyle="darkGrey"
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
@@ -129,6 +264,7 @@ class Stream extends React.Component {
<QuestionBox
content={asset.settings.questionBoxContent}
enable={asset.settings.questionBoxEnable}
icon={asset.settings.questionBoxIcon}
/>
{!banned &&
temporarilySuspended &&
@@ -147,7 +283,7 @@ class Stream extends React.Component {
/>}
{showCommentBox &&
<CommentBox
addNotification={addNotification}
notify={notify}
postComment={postComment}
appendItemArray={appendItemArray}
updateItem={updateItem}
@@ -174,103 +310,19 @@ class Stream extends React.Component {
/>
)}
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
? (
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
<Comment
data={data}
root={root}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
depth={0}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
highlighted={comment.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={highlightedComment.id}
commentIsIgnored={this.commentIsIgnored}
comment={highlightedComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
liveUpdates={true}
/>
</div>
)
: <div className={cn('talk-stream-tab-container', styles.tabContainer)}>
<div
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
>
<Slot
fill="streamFilter"
queryData={slotQueryData}
{...slotProps}
/>
</div>
<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>
}
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>
}
sub
/>
</div>
<SpinnerWhenLoading loading={loading}>
{highlightedComment
? this.renderHighlightedComment()
: this.renderTabPanel()
}
</SpinnerWhenLoading>
</div>
);
}
}
Stream.propTypes = {
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
// dispatch action to ignore another user
@@ -18,7 +18,7 @@ export class TopRightMenu extends React.Component {
ignoreUser: PropTypes.func,
// show notification to the user (e.g. for errors)
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
@@ -27,7 +27,7 @@ export class TopRightMenu extends React.Component {
};
}
render() {
const {comment, ignoreUser, addNotification} = this.props;
const {comment, ignoreUser, notify} = this.props;
// timesReset is used as Toggleable key so it re-renders on reset (closing the toggleable)
const reset = () => this.setState({timesReset: this.state.timesReset + 1});
@@ -40,7 +40,7 @@ export class TopRightMenu extends React.Component {
try {
await ignoreUser({id});
} catch (error) {
addNotification('error', 'Failed to ignore user');
notify('error', 'Failed to ignore user');
throw error;
}
};
@@ -6,3 +6,4 @@ export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL;
export const SET_ACTIVE_TAB = 'CORAL_STREAM_SET_ACTIVE_TAB';
export const SET_SORT = 'CORAL_STREAM_SET_SORT';
@@ -105,7 +105,7 @@ const withCommentFragments = withFragments({
fragment CoralEmbedStream_Comment_comment on Comment {
...CoralEmbedStream_Comment_SingleComment
${nest(`
replies(limit: 3, excludeIgnored: $excludeIgnored) {
replies(query: {limit: 3, excludeIgnored: $excludeIgnored}) {
nodes {
...CoralEmbedStream_Comment_SingleComment
...nest
@@ -8,22 +8,25 @@ import branch from 'recompose/branch';
import renderComponent from 'recompose/renderComponent';
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 * as authActions from '../actions/auth';
import * as assetActions from '../actions/asset';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from './Stream';
import {addNotification} from 'coral-framework/actions/notification';
import {notify} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
import {setActiveTab} from '../actions/embed';
const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions;
const {fetchAssetSuccess} = assetActions;
class EmbedContainer extends React.Component {
static contextTypes = {
pym: PropTypes.object,
};
subscriptions = [];
subscribeToUpdates(props = this.props) {
@@ -31,19 +34,19 @@ class EmbedContainer extends React.Component {
const newSubscriptions = [{
document: USER_BANNED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_account_has_been_banned'));
notify('info', t('your_account_has_been_banned'));
},
},
{
document: USER_SUSPENDED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_account_has_been_suspended'));
notify('info', t('your_account_has_been_suspended'));
},
},
{
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_username_has_been_rejected'));
notify('info', t('your_username_has_been_rejected'));
},
}];
@@ -95,7 +98,7 @@ class EmbedContainer extends React.Component {
if (!get(prevProps, 'root.asset.comment') && get(this.props, 'root.asset.comment')) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0);
setTimeout(() => this.context.pym.scrollParentToChildEl('talk-embed-stream-container'), 0);
}
}
@@ -151,7 +154,15 @@ const slots = [
];
const EMBED_QUERY = gql`
query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
query CoralEmbedStream_Embed(
$assetId: ID,
$assetUrl: String,
$commentId: ID!,
$hasComment: Boolean!,
$excludeIgnored: Boolean,
$sortBy: SORT_COMMENTS_BY!,
$sortOrder: SORT_ORDER!,
) {
me {
id
status
@@ -163,13 +174,15 @@ const EMBED_QUERY = gql`
`;
export const withEmbedQuery = withQuery(EMBED_QUERY, {
options: ({auth, commentId, assetId, assetUrl}) => ({
options: ({auth, commentId, assetId, assetUrl, sortBy, sortOrder}) => ({
variables: {
assetId,
assetUrl,
commentId,
hasComment: commentId !== '',
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
sortBy,
sortOrder,
},
}),
});
@@ -180,7 +193,9 @@ const mapStateToProps = (state) => ({
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
activeTab: state.embed.activeTab,
config: state.config
config: state.config,
sortOrder: state.stream.sortOrder,
sortBy: state.stream.sortBy,
});
const mapDispatchToProps = (dispatch) =>
@@ -190,7 +205,7 @@ const mapDispatchToProps = (dispatch) =>
checkLogin,
setActiveTab,
fetchAssetSuccess,
addNotification,
notify,
focusSignInDialog,
blurSignInDialog,
hideSignInDialog,
@@ -8,7 +8,7 @@ import {
withDeleteAction, withIgnoreUser, withEditComment
} from 'coral-framework/graphql/mutations';
import * as authActions from 'coral-framework/actions/auth';
import * as authActions from 'coral-embed-stream/src/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
import Stream from '../components/Stream';
@@ -26,14 +26,18 @@ import {
} from '../graphql/utils';
const {showSignInDialog, editName} = authActions;
const {addNotification} = notificationActions;
const {notify} = notificationActions;
class StreamContainer extends React.Component {
subscriptions = [];
commentsAddedSubscription = null;
commentsEditedSubscription = null;
subscribeToUpdates() {
const newSubscriptions = [{
subscribeToCommentsEdited() {
this.commentsEditedSubscription = this.props.data.subscribeToMore({
document: COMMENTS_EDITED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
// Ignore mutations from me.
@@ -51,9 +55,15 @@ class StreamContainer extends React.Component {
return removeCommentFromEmbedQuery(prev, commentEdited.id);
}
},
},
{
});
}
subscribeToCommentsAdded() {
this.commentsAddedSubscription = this.props.data.subscribeToMore({
document: COMMENTS_ADDED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
// Ignore mutations from me.
@@ -75,21 +85,22 @@ class StreamContainer extends React.Component {
}
return insertCommentIntoEmbedQuery(prev, commentAdded);
}
}];
this.subscriptions = newSubscriptions.map((s) => this.props.data.subscribeToMore({
document: s.document,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: s.updateQuery,
}));
});
}
unsubscribe() {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
unsubscribeCommentsAdded() {
if (this.commentsAddedSubscription) {
this.commentsAddedSubscription();
this.commentsAddedSubscription = null;
}
}
unsubscribeCommentsEdited() {
if (this.commentsEditedSubscription) {
this.commentsEditedSubscription();
this.commentsEditedSubscription = null;
}
}
loadNewReplies = (parent_id) => {
@@ -102,7 +113,7 @@ class StreamContainer extends React.Component {
cursor: comment.replies.endCursor,
parent_id,
asset_id: this.props.root.asset.id,
sort: 'CHRONOLOGICAL',
sortOrder: 'ASC',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
@@ -119,7 +130,8 @@ class StreamContainer extends React.Component {
cursor: this.props.root.asset.comments.endCursor,
parent_id: null,
asset_id: this.props.root.asset.id,
sort: 'REVERSE_CHRONOLOGICAL',
sortOrder: this.props.data.variables.sortOrder,
sortBy: this.props.data.variables.sortBy,
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
@@ -128,36 +140,78 @@ class StreamContainer extends React.Component {
});
};
isSortedByNewestFirst({sortBy, sortOrder} = this.props) {
return sortBy === 'CREATED_AT' && sortOrder === 'DESC';
}
componentDidMount() {
if (this.props.previousTab) {
this.props.data.refetch();
}
this.subscribeToUpdates();
if (this.isSortedByNewestFirst()) {
this.subscribeToCommentsAdded();
}
this.subscribeToCommentsEdited();
}
componentWillUnmount() {
this.unsubscribe();
this.unsubscribeCommentsAdded();
this.unsubscribeCommentsEdited();
clearInterval(this.countPoll);
}
componentWillReceiveProps(nextProps) {
const prevSortedNewest = this.isSortedByNewestFirst(this.props);
const nextSortedNewest = this.isSortedByNewestFirst(nextProps);
// When switching to 'Newest first' we refetch and subscribe so that
// we always have the newest comments.
if (!prevSortedNewest && nextSortedNewest) {
nextProps.data.refetch();
this.subscribeToCommentsAdded();
}
// When switching away from 'Newest first' unsubscribe from newest comments.
if (prevSortedNewest && !nextSortedNewest) {
this.unsubscribeCommentsAdded();
}
}
shouldComponentUpdate(nextProps) {
const prevSortedNewest = this.isSortedByNewestFirst(this.props);
const nextSortedNewest = this.isSortedByNewestFirst(nextProps);
if (!prevSortedNewest && nextSortedNewest) {
// When switching to 'Newest first' we refetch => skip
// rendering this frame and wait for refetch to kick in.
return false;
}
return true;
}
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
if (this.props.refetching
|| !this.props.root.asset
if (!this.props.root.asset
|| !this.props.root.asset.comment
&& !this.props.root.asset.comments
) {
return <Spinner />;
}
const streamLoading = this.props.refetching || this.props.data.loading;
return <Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
loading={streamLoading}
/>;
}
}
@@ -203,8 +257,26 @@ const COMMENTS_EDITED_SUBSCRIPTION = gql`
`;
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}) {
query CoralEmbedStream_LoadMoreComments(
$limit: Int = 5
$cursor: Cursor
$parent_id: ID
$asset_id: ID
$sortOrder: SORT_ORDER
$sortBy: SORT_COMMENTS_BY = CREATED_AT
$excludeIgnored: Boolean
) {
comments(
query: {
limit: $limit
cursor: $cursor
parent_id: $parent_id
asset_id: $asset_id
sortOrder: $sortOrder
sortBy: $sortBy
excludeIgnored: $excludeIgnored
}
) {
nodes {
...CoralEmbedStream_Stream_comment
}
@@ -219,6 +291,7 @@ const LOAD_MORE_QUERY = gql`
const slots = [
'streamTabs',
'streamTabPanes',
'streamFilter',
];
const fragments = {
@@ -247,15 +320,16 @@ const fragments = {
premodLinksEnable
questionBoxEnable
questionBoxContent
questionBoxIcon
closeTimeout
closedMessage
charCountEnable
charCount
requireEmailConfirmation
}
commentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
totalCommentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
comments(limit: 10, excludeIgnored: $excludeIgnored) @skip(if: $hasComment) {
commentCount @skip(if: $hasComment)
totalCommentCount @skip(if: $hasComment)
comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) {
nodes {
...CoralEmbedStream_Stream_comment
}
@@ -298,12 +372,14 @@ const mapStateToProps = (state) => ({
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
sortOrder: state.stream.sortOrder,
sortBy: state.stream.sortBy,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showSignInDialog,
addNotification,
notify,
setActiveReplyBox,
editName,
viewAllComments,
@@ -2,13 +2,15 @@ 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 {
static contextTypes = {
plugins: PropTypes.object,
};
componentDidMount() {
this.fallbackAllTab();
@@ -43,28 +45,37 @@ class StreamTabPanelContainer extends React.Component {
}
getSlotComponents(slot, props = this.props) {
return getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData);
const {plugins} = this.context;
return plugins.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>
));
const {plugins} = this.context;
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => {
const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData);
return (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...pluginProps}
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>
));
const {plugins} = this.context;
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => {
const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData);
return (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...pluginProps}
/>
</TabPane>
);
});
}
render() {
@@ -1,10 +1,9 @@
import {gql} from 'react-apollo';
import {add} from 'coral-framework/services/graphqlRegistry';
import update from 'immutability-helper';
import uuid from 'uuid/v4';
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
const extension = {
export default {
fragments: {
EditCommentResponse: gql`
fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse {
@@ -223,4 +222,3 @@ const extension = {
},
};
add(extension);
+9 -12
View File
@@ -1,5 +1,5 @@
import update from 'immutability-helper';
import {insertCommentsSorted} from 'coral-framework/utils';
import {appendNewNodes} from 'coral-framework/utils';
function determineCommentDepth(comment) {
let depth = 0;
@@ -36,11 +36,15 @@ function applyToCommentsOrigin(root, callback) {
}
function findAndInsertComment(parent, comment) {
const [connectionField, countField, action] = parent.__typename === 'Asset'
const isAsset = parent.__typename === 'Asset';
const [connectionField, countField, action] = isAsset
? ['comments', 'commentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (!comment.parent || parent.id === comment.parent.id) {
if (
!comment.parent && isAsset // A top level comment in the asset.
|| comment.parent && parent.id === comment.parent.id // A reply at the correct parent.
) {
return update(parent, {
[connectionField]: {
nodes: {[action]: [comment]},
@@ -159,14 +163,7 @@ function findAndInsertFetchedComments(parent, comments, parent_id) {
[connectionField]: {
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => {
if (isAsset) {
return nodes.concat(comments.nodes);
}
return insertCommentsSorted(nodes, comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
));
}},
nodes: {$apply: (nodes) => appendNewNodes(nodes, comments.nodes)},
},
});
}
@@ -198,7 +195,7 @@ export function attachCommentToParent(topLevelComment, comment) {
return update(topLevelComment, {
replies: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comment),
$apply: (nodes) => appendNewNodes(nodes, [comment]),
},
},
replyCount: {
+11 -29
View File
@@ -1,27 +1,19 @@
import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {checkLogin, handleAuthToken, logout} from 'coral-framework/actions/auth';
import './graphql';
import {checkLogin, handleAuthToken, logout} from 'coral-embed-stream/src/actions/auth';
import graphqlExtension from './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import {getStore, injectReducers, addListener} from 'coral-framework/services/store';
import {getClient} from 'coral-framework/services/client';
import {createReduxEmitter} from 'coral-framework/services/events';
import pym from 'coral-framework/services/pym';
import {createContext} from 'coral-framework/services/bootstrap';
import AppRouter from './AppRouter';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
import EventEmitter from 'eventemitter2';
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
import TalkProvider from 'coral-framework/components/TalkProvider';
import pluginsConfig from 'pluginsConfig';
const store = getStore();
const client = getClient();
const eventEmitter = new EventEmitter({wildcard: true});
const context = createContext({reducers, graphqlExtension, pluginsConfig});
loadPluginsTranslations();
injectPluginsReducers();
injectReducers(reducers);
// TODO: move init code into `bootstrap` service after auth has been refactored.
const {store, pym} = context;
function inIframe() {
try {
@@ -57,21 +49,11 @@ if (!window.opener) {
} else {
init();
}
// Pass any events through our parent.
eventEmitter.onAny((eventName, value) => {
pym.sendMessage('event', JSON.stringify({eventName, value}));
});
// Add a redux listener to pass through all actions to our event emitter.
addListener(createReduxEmitter(eventEmitter));
}
render(
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={client} store={store}>
<AppRouter />
</ApolloProvider>
</EventEmitterProvider>
<TalkProvider {...context}>
<AppRouter />
</TalkProvider>
, document.querySelector('#talk-embed-stream-container')
);
@@ -1,8 +1,14 @@
import auth from './auth';
import asset from './asset';
import embed from './embed';
import config from './config';
import stream from './stream';
import {reducer as commentBox} from '../../../talk-plugin-commentbox';
export default {
auth,
asset,
commentBox,
embed,
config,
stream,
@@ -1,5 +1,5 @@
import * as actions from '../constants/stream';
import * as authActions from 'coral-framework/constants/auth';
import * as authActions from '../constants/auth';
function getQueryVariable(variable) {
let query = window.location.search.substring(1);
@@ -23,6 +23,8 @@ const initialState = {
commentClassNames: [],
activeTab: process.env.TALK_DEFAULT_STREAM_TAB,
previousTab: '',
sortBy: 'CREATED_AT',
sortOrder: 'DESC',
};
export default function stream(state = initialState, action) {
@@ -66,6 +68,12 @@ export default function stream(state = initialState, action) {
...state.commentClassNames.slice(action.idx + 1)
]
};
case actions.SET_SORT :
return {
...state,
sortOrder: action.sortOrder ? action.sortOrder : state.sortOrder,
sortBy: action.sortBy ? action.sortBy : state.sortBy,
};
default:
return state;
}
+6 -61
View File
@@ -79,6 +79,12 @@ body {
}
/* Info Box Styles */
.hidden {
visibility: hidden;
display: none;
}
.talk-plugin-infobox-info {
top: 0;
border: 0;
@@ -93,7 +99,6 @@ body {
border-radius: 2px;
}
.talk-plugin-infobox-info em{
font-style: italic;
}
@@ -113,66 +118,6 @@ body {
}
/* Question Box Styles */
.talk-plugin-questionbox-info {
top: 0;
border: 0;
background: #F0F0F0;
color: black;
width: 100%;
text-align: left;
padding-left: 0px;
margin-bottom: 0px;
font-weight: bold;
font-size: 14px;
overflow: hidden;
min-height: 50px;
display: flex;
}
.talk-plugin-questionbox-icon.bubble{
position: absolute;
top: 8px;
left: 10px;
color: #9E9E9E;
font-size: 24px;
z-index: 0;
}
.talk-plugin-questionbox-icon.person{
z-index: 2;
top: 12px;
left: 12px;
position: absolute;
font-size: 33px;
color: #262626;
}
.talk-plugin-questionbox-box {
position: relative;
border: 0;
color: white;
padding: 20px;
margin-left: 0px !important;
margin-right: 10px;
display: inline-block;
width: 10px;
min-height: 100%;
padding: 5px 20px;
vertical-align: middle;
}
.talk-plugin-questionbox-content {
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 400;
}
.hidden {
visibility: hidden;
display: none;
}
.talk-stream-comments-container {
position: relative;
+18 -8
View File
@@ -1,12 +1,22 @@
import pym from '../services/pym';
import * as actions from '../constants/notification';
export const addNotification = (notifType, text) => {
pym.sendMessage('coral-alert', `${notifType}|${text}`);
return {type: actions.ADD_NOTIFICATION, notifType, text};
};
export const notify = (kind, msg) => (dispatch, _, {notification}) => {
const messages = Array.isArray(msg) ? msg : [msg];
export const clearNotification = () => {
pym.sendMessage('coral-clear-notification');
return {type: actions.CLEAR_NOTIFICATION};
messages.forEach((message) => {
switch (kind) {
case 'error':
notification.error(message);
break;
case 'info':
notification.info(message);
break;
case 'success':
notification.success(message);
break;
default:
throw new Error(`Unknown notification kind ${kind}`);
}
dispatch({type: actions.NOTIFY, kind, message});
});
};
@@ -1,13 +1,16 @@
import {Component, cloneElement, Children} from 'react';
import PropTypes from 'prop-types';
import {findDOMNode} from 'react-dom';
import pym from 'coral-framework/services/pym';
export default class ClickOutside extends Component {
static propTypes = {
onClickOutside: PropTypes.func.isRequired
};
static contextTypes = {
pym: PropTypes.object,
};
domNode = null;
handleClick = (e) => {
@@ -18,14 +21,20 @@ export default class ClickOutside extends Component {
};
componentDidMount() {
const {pym} = this.context;
this.domNode = findDOMNode(this);
document.addEventListener('click', this.handleClick, true);
pym.onMessage('click', this.handleClick);
if (pym) {
pym.onMessage('click', this.handleClick);
}
}
componentWillUnmount() {
const {pym} = this.context;
document.removeEventListener('click', this.handleClick, true);
pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick);
if (pym) {
pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick);
}
}
render() {
@@ -1,18 +0,0 @@
import React from 'react';
const PropTypes = require('prop-types');
class EventEmitterProvider extends React.Component {
getChildContext() {
return {eventEmitter: this.props.eventEmitter};
}
render() {
return this.props.children;
}
}
EventEmitterProvider.childContextTypes = {
eventEmitter: PropTypes.object,
};
export default EventEmitterProvider;
@@ -1,11 +1,13 @@
import React from 'react';
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';
class IfSlotIsEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
@@ -22,7 +24,7 @@ class IfSlotIsEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
return this.context.plugins.isSlotEmpty(slot, reduxState, rest);
}
render() {
@@ -1,11 +1,13 @@
import React from 'react';
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';
class IfSlotIsNotEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
@@ -22,7 +24,7 @@ class IfSlotIsNotEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
return this.context.plugins.isSlotEmpty(slot, reduxState, rest);
}
render() {
@@ -0,0 +1,23 @@
import React, {PureComponent, PropTypes} from 'react';
import marked from 'marked';
const renderer = new marked.Renderer();
// Set link target to `_parent` to work properly in an embed.
renderer.link = (href, title, text) =>
`<a target="_parent" href="${href}" ${title ? `title="${title}"` : ''}>${text}</a>`;
marked.setOptions({renderer});
export default class Markdown extends PureComponent {
render() {
const {content, ...rest} = this.props;
const __html = marked(content);
return <div {...rest} dangerouslySetInnerHTML={{__html}} />;
}
}
Markdown.propTypes = {
content: PropTypes.string,
};
@@ -467,7 +467,6 @@ $fullscreenZIndex: 10;
text-align: center;
text-decoration: none!important;
color: #2c3e50!important;
width: 30px;
height: 30px;
margin: 0;
border: 1px solid transparent;
@@ -475,6 +474,8 @@ $fullscreenZIndex: 10;
cursor: pointer;
outline: 0;
margin-right: 2px;
font-size: 1.5em;
width: 25px;
&.active {
background: #fcfcfc;
border-color: #95a5a6;
+61 -9
View File
@@ -2,14 +2,18 @@ import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import omit from 'lodash/omit';
import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual';
import {getShallowChanges} from 'coral-framework/utils';
const emptyConfig = {};
class Slot extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
@@ -25,35 +29,83 @@ class Slot extends React.Component {
return changes.length !== 0;
}
getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, queryData: _f, ...rest} = this.props) {
getSlotProps(props = this.props) {
const {
fill: _a,
inline: _b,
className: _c,
reduxState: _d,
defaultComponent_: _e,
queryData: _f,
childFactory: _g,
component: _h,
...rest
} = props;
return rest;
}
getChildren(props = this.props) {
return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
const {plugins} = this.context;
return plugins.getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
}
render() {
const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props;
const {
inline = false,
className,
reduxState,
component: Component,
childFactory,
defaultComponent: DefaultComponent,
queryData,
} = this.props;
const {plugins} = this.context;
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)} />;
const props = plugins.getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData);
children = <DefaultComponent {...props} />;
}
if (childFactory) {
children = children.map(childFactory);
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
<Component className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
</Component>
);
}
}
Slot.defaultProps = {
component: 'div',
};
Slot.propTypes = {
fill: React.PropTypes.string.isRequired,
fill: PropTypes.string.isRequired,
/**
* You may specify the component to use as the root wrapper.
* Defaults to 'div'.
*/
component: PropTypes.any,
// props coming from graphql must be passed through this property.
queryData: React.PropTypes.object,
queryData: PropTypes.object,
/**
* You may need to apply reactive updates to a child as it is exiting.
* This is generally done by using `cloneElement` however in the case of an exiting
* child the element has already been removed and not accessible to the consumer.
*
* If you do need to update a child as it leaves you can provide a `childFactory`
* to wrap every child, even the ones that are leaving.
*
* @type Function(child: ReactElement) -> ReactElement
*/
childFactory: PropTypes.func,
};
const mapStateToProps = (state) => ({
@@ -0,0 +1,40 @@
import React from 'react';
const PropTypes = require('prop-types');
import {ApolloProvider} from 'react-apollo';
class TalkProvider extends React.Component {
getChildContext() {
return {
eventEmitter: this.props.eventEmitter,
pym: this.props.pym,
plugins: this.props.plugins,
rest: this.props.rest,
graphqlRegistry: this.props.graphqlRegistry,
notification: this.props.notification,
storage: this.props.storage,
history: this.props.history,
};
}
render() {
const {children, client, store} = this.props;
return (
<ApolloProvider client={client} store={store}>
{children}
</ApolloProvider>
);
}
}
TalkProvider.childContextTypes = {
pym: PropTypes.object,
eventEmitter: PropTypes.object,
plugins: PropTypes.object,
rest: PropTypes.func,
graphqlRegistry: PropTypes.object,
notification: PropTypes.object,
storage: PropTypes.object,
history: PropTypes.object,
};
export default TalkProvider;
@@ -1,2 +1 @@
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION';
export const NOTIFY = 'NOTIFY';
-173
View File
@@ -1,173 +0,0 @@
import React from 'react';
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';
// 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`
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components))
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot])
)
.filter((component) => {
if(!component.isExcluded) {
return true;
}
let resolvedProps = getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
return !component.isExcluded(resolvedProps);
});
}
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 = {}, 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) {
const components = uniq(flattenDeep(plugins
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
.map((o) => o.module.slots[slot])
));
const documents = components
.map((c) => c.fragments)
.filter((fragments) => fragments && fragments[part])
.reduce((res, fragments) => {
res.push(fragments[part]);
return res;
}, []);
return documents;
}
export function getGraphQLExtensions() {
return plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => !isEmpty(o));
}
export function getModQueueConfigs() {
return merge(...plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
function getTranslations() {
return plugins
.map((o) => o.module.translations)
.filter((o) => o);
}
export function loadPluginsTranslations() {
getTranslations().forEach((t) => loadTranslations(t));
}
export function injectPluginsReducers() {
const reducers = merge(
...plugins
.filter((o) => o.module.reducer)
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
);
injectReducers(reducers);
}
function addMetaDataToSlotComponents() {
// Add talkPluginName to Slot Components.
plugins.forEach((plugin) => {
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();
});
});
});
}
addMetaDataToSlotComponents();
-84
View File
@@ -1,84 +0,0 @@
import bowser from 'bowser';
import * as Storage from './storage';
import merge from 'lodash/merge';
import {getStore} from 'coral-framework/services/store';
import {BASE_PATH} from 'coral-framework/constants/url';
/**
* getAuthToken returns the active auth token or null
* Note: this method does not have access to the cookie based token used by
* browsers that don't allow us to use cross domain iframe local storage.
* @return {string|null}
*/
export const getAuthToken = () => {
let state = getStore().getState();
if (state.config.auth_token) {
// if an auth_token exists in config, use it.
return state.config.auth_token;
} else if (!bowser.safari && !bowser.ios) {
// Use local storage auth tokens where there's a stable api.
return Storage.getItem('token');
}
return null;
};
const buildOptions = (inputOptions = {}) => {
const defaultOptions = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin'
};
let options = merge({}, defaultOptions, inputOptions);
// Apply authToken header
let authToken = getAuthToken();
if (authToken !== null) {
options.headers.Authorization = `Bearer ${authToken}`;
}
if (options.method.toLowerCase() !== 'get') {
options.body = JSON.stringify(options.body);
}
return options;
};
const handleResp = (res) => {
if (res.status > 399) {
return res.json().then((err) => {
let message = err.message || res.status;
const error = new Error();
if (err.error && err.error.metadata && err.error.metadata.message) {
error.metadata = err.error.metadata.message;
}
if (err.error && err.error.translation_key) {
error.translation_key = err.error.translation_key;
}
error.message = message;
error.status = res.status;
throw error;
});
} else if (res.status === 204) {
return res.text();
} else {
return res.json();
}
};
export const base = `${BASE_PATH}api/v1`;
export default (url, options) => {
return fetch(`${base}${url}`, buildOptions(options)).then(handleResp);
};
-7
View File
@@ -1,7 +0,0 @@
import {useBasename} from 'history';
import {browserHistory} from 'react-router';
import {BASE_PATH} from 'coral-framework/constants/url';
export const history = useBasename(() => browserHistory)({
basename: BASE_PATH
});
-92
View File
@@ -1,92 +0,0 @@
let available, error;
function storageAvailable(type) {
let storage = window[type], x = '__storage_test__';
try {
storage.setItem(x, x);
storage.removeItem(x);
return true;
} catch (e) {
error = e;
return (
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0
);
}
}
function lazyCheckStorage() {
if (typeof available === 'undefined') {
available = storageAvailable('localStorage');
}
}
export function getItem(item = '') {
lazyCheckStorage();
if (available) {
return localStorage.getItem(item);
} else {
console.error(
`Cannot get from localStorage. localStorage is not available. ${error}`
);
}
}
export function setItem(item = '', value) {
lazyCheckStorage();
if (available) {
return localStorage.setItem(item, value);
} else {
console.error(
`Cannot set localStorage. localStorage is not available. ${error}`
);
}
}
export function removeItem(item = '') {
lazyCheckStorage();
if (available) {
return localStorage.removeItem(item);
} else {
console.error(
`Cannot remove item from localStorage. localStorage is not available. ${error}`
);
}
}
export function clear() {
lazyCheckStorage();
if (available) {
return localStorage.clear();
} else {
console.error(
`Cannot clear localStorage. localStorage is not available. ${error}`
);
}
}
// window.addEventListener('storage', function(e) {
// const msg = `${e.key} " was changed in page ${e.url} from ${e.oldValue} to ${e.newValue}`;
// console.log(msg);
// });
+17 -2
View File
@@ -1,9 +1,9 @@
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 PropTypes from 'prop-types';
import union from 'lodash/union';
// TODO: Should not depend on `props.data`
@@ -63,7 +63,22 @@ function hasEqualLeaves(a, b, path = '') {
export default (fragments) => hoistStatics((BaseComponent) => {
class WithFragments extends React.Component {
fragments = mapValues(fragments, (val) => resolveFragments(val));
static contextTypes = {
graphqlRegistry: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
fragments = mapValues(fragments, (val) => this.resolveDocument(val));
fragmentKeys = Object.keys(fragments).sort();
// Cache variables between lifecycles to speed up render.
+14 -3
View File
@@ -4,7 +4,6 @@ import merge from 'lodash/merge';
import uniq from 'lodash/uniq';
import flatten from 'lodash/flatten';
import isEmpty from 'lodash/isEmpty';
import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
import {getDefinitionName, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
@@ -43,8 +42,20 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
static contextTypes = {
eventEmitter: PropTypes.object,
store: PropTypes.object,
graphqlRegistry: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
@@ -56,7 +67,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
propsWrapper = (data) => {
const name = getDefinitionName(document);
const callbacks = getMutationOptions(name);
const callbacks = this.graphqlRegistry.getMutationOptions(name);
const mutate = (base) => {
const variables = base.variables || config.options.variables;
const configs = callbacks.map((cb) => cb({variables, state: this.context.store.getState()}));
@@ -167,7 +178,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
getWrapped = () => {
if (!this.memoized) {
this.memoized = graphql(resolveFragments(document), {...config, props: this.propsWrapper})(WrappedComponent);
this.memoized = graphql(this.resolveDocument(document), {...config, props: this.propsWrapper})(WrappedComponent);
}
return this.memoized;
};
+22 -8
View File
@@ -1,6 +1,5 @@
import * as React from 'react';
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';
@@ -37,17 +36,28 @@ function networkStatusToString(networkStatus) {
* apply query options registered in the graphRegistry.
*/
export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const name = getDefinitionName(document);
return class WithQuery extends React.Component {
static contextTypes = {
eventEmitter: PropTypes.object,
graphqlRegistry: PropTypes.object,
};
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
lastNetworkStatus = null;
data = null;
name = '';
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
emitWhenNeeded(data) {
const {variables, networkStatus} = data;
@@ -93,11 +103,12 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMore: (stmArgs) => {
const resolvedDocument = this.resolveDocument(stmArgs.document);
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
...stmArgs,
document: resolveFragments(stmArgs.document),
document: resolvedDocument,
onError: (err) => {
if (stmArgs.onErr) {
return stmArgs.onErr(err);
@@ -107,7 +118,8 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
});
},
fetchMore: (lmArgs) => {
const fetchName = getDefinitionName(lmArgs.query);
const resolvedDocument = this.resolveDocument(lmArgs.query);
const fetchName = getDefinitionName(resolvedDocument);
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.begin`,
{variables: lmArgs.variables});
@@ -115,7 +127,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
...lmArgs,
query: resolveFragments(lmArgs.query),
query: resolvedDocument,
})
.then((res) => {
this.context.eventEmitter.emit(
@@ -156,7 +168,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const base = (typeof this.wrappedConfig.options === 'function')
? this.wrappedConfig.options(data)
: this.wrappedConfig.options;
const configs = getQueryOptions(name);
const configs = this.graphqlRegistry.getQueryOptions(name);
const reducerCallbacks =
[base.reducer || ((i) => i)]
.concat(...configs.map((cfg) => cfg.reducer))
@@ -178,8 +190,10 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
getWrapped = () => {
if (!this.memoized) {
const resolvedDocument = this.resolveDocument(document);
this.name = getDefinitionName(resolvedDocument);
this.memoized = graphql(
resolveFragments(document),
resolvedDocument,
{...this.wrappedConfig, options: this.wrappedOptions},
)(WrappedComponent);
}
-9
View File
@@ -1,9 +0,0 @@
import auth from './auth';
import asset from './asset';
import {reducer as commentBox} from '../../talk-plugin-commentbox';
export default {
auth,
asset,
commentBox,
};
+128
View File
@@ -0,0 +1,128 @@
import {createStore} from './store';
import {createClient, apolloErrorReporter} from './client';
import pym from './pym';
import EventEmitter from 'eventemitter2';
import {createReduxEmitter} from './events';
import {createRestClient} from './rest';
import thunk from 'redux-thunk';
import {loadTranslations} from './i18n';
import bowser from 'bowser';
import {BASE_PATH} from 'coral-framework/constants/url';
import {createPluginsService} from './plugins';
import {createNotificationService} from './notification';
import {createGraphQLRegistry} from './graphqlRegistry';
import globalFragments from 'coral-framework/graphql/fragments';
import {createStorage} from 'coral-framework/services/storage';
import {createHistory} from 'coral-framework/services/history';
/**
* getAuthToken returns the active auth token or null
* Note: this method does not have access to the cookie based token used by
* browsers that don't allow us to use cross domain iframe local storage.
* @return {string|null}
*/
const getAuthToken = (store, storage) => {
let state = store.getState();
if (state.config.auth_token) {
// if an auth_token exists in config, use it.
return state.config.auth_token;
} else if (!bowser.safari && !bowser.ios && storage) {
// Use local storage auth tokens where there's a stable api.
return storage.getItem('token');
}
return null;
};
/**
* createContext setups and returns Talk dependencies that should be
* passed to `TalkProvider`.
* @param {Object} [config] configuration
* @param {Object} [config.reducers] extra reducers to add to redux
* @param {Array} [config.pluginsConfig] plugin configuration as returned by importing pluginsConfig
* @param {Object} [config.graphqlExtensions] additional extension to the graphql framework
* @param {Object} [config.notification] replace default notification service
* @return {Object} context
*/
export function createContext({reducers = {}, pluginsConfig = [], graphqlExtension = {}, notification} = {}) {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const eventEmitter = new EventEmitter({wildcard: true});
const storage = createStorage();
const history = createHistory(BASE_PATH);
let store = null;
const token = () => {
// Try to get the token from localStorage. If it isn't here, it may
// be passed as a cookie.
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return getAuthToken(store, storage);
};
const rest = createRestClient({
uri: `${BASE_PATH}api/v1`,
token,
});
const client = createClient({
uri: `${BASE_PATH}api/v1/graph/ql`,
liveUri: `${protocol}://${location.host}${BASE_PATH}api/v1/live`,
token,
});
const plugins = createPluginsService(pluginsConfig);
const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins));
if (!notification) {
// Use default notification service (pym based)
notification = createNotificationService(pym);
}
const context = {
client,
pym,
plugins,
eventEmitter,
rest,
graphqlRegistry,
notification,
storage,
history,
};
// Load framework fragments.
Object.keys(globalFragments).forEach((key) => graphqlRegistry.addFragment(key, globalFragments[key]));
// Register graphql extension
graphqlRegistry.add(graphqlExtension);
// Register plugin graphql extensions.
plugins.getGraphQLExtensions().forEach((ext) => graphqlRegistry.add(ext));
// Load plugin translations.
plugins.getTranslations().forEach((t) => loadTranslations(t));
// Pass any events through our parent.
eventEmitter.onAny((eventName, value) => {
pym.sendMessage('event', JSON.stringify({eventName, value}));
});
const finalReducers = {
...reducers,
...plugins.getReducers(),
apollo: client.reducer(),
};
store = createStore(finalReducers, [
client.middleware(),
thunk.withExtraArgument(context),
apolloErrorReporter,
createReduxEmitter(eventEmitter),
]);
return {
...context,
store,
};
}
+59 -42
View File
@@ -1,64 +1,66 @@
import ApolloClient, {addTypename, IntrospectionFragmentMatcher} from 'apollo-client';
import {networkInterface} from './transport';
import ApolloClient, {addTypename, IntrospectionFragmentMatcher, createNetworkInterface} from 'apollo-client';
import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
import MessageTypes from 'subscriptions-transport-ws/dist/message-types';
import {getAuthToken} from '../helpers/request';
import introspectionQueryResultData from '../graphql/introspection.json';
import {BASE_PATH} from 'coral-framework/constants/url';
let client, wsClient = null, wsClientToken = null;
export function resetWebsocket() {
if (wsClient === null) {
// Nothing to reset!
return;
// Redux middleware to report any errors to the console.
export const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close();
// Reconnect to the server.
wsClient.connect();
// Reregister all subscriptions (uses non public api).
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
Object.keys(wsClient.operations).forEach((id) => {
wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options);
});
function resolveToken(token) {
return typeof token === 'function' ? token() : token;
}
export function getClient(options = {}) {
if (client) {
return client;
}
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
wsClient = new SubscriptionClient(`${protocol}://${location.host}${BASE_PATH}api/v1/live`, {
/**
* createClient setups and returns an Apollo GraphQL Client
* @param {Object} [options] configuration
* @param {string|function} [options.token] auth token
* @param {string} [options.uri] uri of the graphql server
* @param {string} [options.liveUri] uri of the graphql subscription server
* @return {Object} apollo client
*/
export function createClient(options = {}) {
const {token, uri, liveUri} = options;
const wsClient = new SubscriptionClient(liveUri, {
reconnect: true,
lazy: true,
connectionParams: {
get token() {
wsClientToken = getAuthToken();
// Try to get the token from localStorage. If it isn't here, it may
// be passed as a cookie.
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return wsClientToken;
}
get token() { return resolveToken(token); },
}
});
const networkInterface = createNetworkInterface({
uri,
opts: {
credentials: 'same-origin'
}
});
networkInterface.use([{
applyMiddleware(req, next) {
if (!req.options.headers) {
req.options.headers = {}; // Create the header object if needed.
}
let authToken = resolveToken(token);
if (authToken) {
req.options.headers['authorization'] = `Bearer ${authToken}`;
}
next();
}
}]);
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
networkInterface,
wsClient,
);
client = new ApolloClient({
...options,
const client = new ApolloClient({
connectToDevTools: true,
addTypename: true,
fragmentMatcher: new IntrospectionFragmentMatcher({introspectionQueryResultData}),
@@ -72,5 +74,20 @@ export function getClient(options = {}) {
networkInterface: networkInterfaceWithSubscriptions,
});
client.resetWebsocket = () => {
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close();
// Reconnect to the server.
wsClient.connect();
// Reregister all subscriptions (uses non public api).
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
Object.keys(wsClient.operations).forEach((id) => {
wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options);
});
};
return client;
}
+10 -2
View File
@@ -1,5 +1,11 @@
/**
* createReduxEmitter returns a redux middleware proxying redux actions to
* the event emitter
* @param {Object} eventEmitter
* @return {function} redux middleware
*/
export function createReduxEmitter(eventEmitter) {
return (action) => {
return () => (next) => (action) => {
// Handle apollo actions.
if (action.type.startsWith('APOLLO_')) {
@@ -7,8 +13,10 @@ export function createReduxEmitter(eventEmitter) {
const {operationName, variables, result: {data}} = action;
eventEmitter.emit(`subscription.${operationName}.data`, {variables, data});
}
return;
return next(action);
}
eventEmitter.emit(`action.${action.type}`, {action});
return next(action);
};
}
+246 -253
View File
@@ -1,6 +1,4 @@
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
import {getGraphQLExtensions, getSlotFragments} from 'coral-framework/helpers/plugins';
import globalFragments from 'coral-framework/graphql/fragments';
import uniq from 'lodash/uniq';
import {gql} from 'react-apollo';
@@ -14,272 +12,267 @@ import {gql} from 'react-apollo';
*/
gql.disableFragmentWarnings();
const fragments = {};
const mutationOptions = {};
const queryOptions = {};
const getTypeName = (ast) => ast.definitions[0].typeCondition.name.value;
/**
* Add fragment
*
* Example:
* addFragment('MyFragment', gql`
* fragment Plugin_MyFragment on Comment {
* body
* }
* `);
*/
export function addFragment(key, document) {
const type = getTypeName(document);
const name = getDefinitionName(document);
if (!(key in fragments)) {
fragments[key] = {type, names: [name], documents: [document]};
} else {
if (type !== fragments[key].type) {
console.error(`Type mismatch ${type} !== ${fragments[key].type}`);
class GraphQLRegistry {
fragments = {};
mutationOptions = {};
queryOptions = {};
constructor(getSlotFragments) {
this.getSlotFragments = getSlotFragments;
}
/**
* Add fragment
*
* Example:
* addFragment('MyFragment', gql`
* fragment Plugin_MyFragment on Comment {
* body
* }
* `);
*/
addFragment(key, document) {
const type = getTypeName(document);
const name = getDefinitionName(document);
if (!(key in this.fragments)) {
this.fragments[key] = {type, names: [name], documents: [document]};
} else {
if (type !== this.fragments[key].type) {
console.error(`Type mismatch ${type} !== ${this.fragments[key].type}`);
}
this.fragments[key].names.push(name);
this.fragments[key].documents.push(document);
}
fragments[key].names.push(name);
fragments[key].documents.push(document);
}
}
/**
* Add mutation options.
*
* Example:
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* addMutationOptions('PostComment', ({variables, state}) => ({
* optimisticResponse: {
* CreateComment: {
* extra: '',
* },
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
*/
export function addMutationOptions(key, config) {
if (!(key in mutationOptions)) {
mutationOptions[key] = [config];
} else {
mutationOptions[key].push(config);
}
}
/**
* Add query options.
*
* Example:
* addQueryOptions('EmbedQuery', {
* reducer: (previousResult, action, variables) => previousResult,
* });
*/
export function addQueryOptions(key, config) {
if (!(key in queryOptions)) {
queryOptions[key] = [config];
} else {
queryOptions[key].push(config);
}
}
/**
* Add all fragments, mutation options, and query options defined in the object.
*
* Example:
* add({
* fragments: {
* CreateCommentResponse: gql`
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
* [...]
* }`,
* },
* mutations: {
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* PostComment: ({variables, state}) => ({
* optimisticResponse: {
* [...]
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
* },
* queries: {
* EmbedQuery: {
* reducer: (previousResult, action, variables) => {
* return previousResult;
* },
* },
* },
* });
*/
export function add(extension) {
Object.keys(extension.fragments || []).forEach((key) => addFragment(key, extension.fragments[key]));
Object.keys(extension.mutations || []).forEach((key) => addMutationOptions(key, extension.mutations[key]));
Object.keys(extension.queries || []).forEach((key) => addQueryOptions(key, extension.queries[key]));
}
/**
* Get a list of mutation options.
*/
export function getMutationOptions(key) {
init();
return mutationOptions[key] || [];
}
/**
* Get a list of query options.
*/
export function getQueryOptions(key) {
init();
return queryOptions[key] || [];
}
/**
* getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource.
* It parses the slot name and the resource and usees the plugin API to assemble
* the fragment document.
*/
function getSlotFragmentDocument(key) {
const match = key.match(/TalkSlot_(.*)_(.*)/);
if (!match) {
return '';
}
const slot = match[1][0].toLowerCase() + match[1].substr(1);
const resource = match[2];
const documents = getSlotFragments(slot, resource);
if (documents.length === 0) {
return '';
}
const names = documents.map((d) => getDefinitionName(d));
const typeName = getTypeName(documents[0]);
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${typeName} {
...${names.join('\n...')}\n
/**
* Add mutation options.
*
* Example:
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* addMutationOptions('PostComment', ({variables, state}) => ({
* optimisticResponse: {
* CreateComment: {
* extra: '',
* },
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
*/
addMutationOptions(key, config) {
if (!(key in this.mutationOptions)) {
this.mutationOptions[key] = [config];
} else {
this.mutationOptions[key].push(config);
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getRegistryFragmentDocument assembles a fragment document using
* all registered fragment under given `key`.
*/
function getRegistryFragmentDocument(key) {
if (!(key in fragments)) {
return '';
}
let documents = fragments[key].documents;
let fields = `...${fragments[key].names.join('\n...')}\n`;
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${fragments[key].type} {
${fields}
/**
* Add query options.
*
* Example:
* addQueryOptions('EmbedQuery', {
* reducer: (previousResult, action, variables) => previousResult,
* });
*/
addQueryOptions(key, config) {
if (!(key in this.queryOptions)) {
this.queryOptions[key] = [config];
} else {
this.queryOptions[key].push(config);
}
`;
return mergeDocuments([main, ...documents]);
}
}
/**
* getFragmentDocument returns a fragment that assembles all registered
* fragments under given `key` or if `key` refers to Slot fragments it will
* return the slot fragments specified by this key.
*/
export function getFragmentDocument(key) {
init();
return getRegistryFragmentDocument(key) || getSlotFragmentDocument(key);
}
/**
* Add all fragments, mutation options, and query options defined in the object.
*
* Example:
* add({
* fragments: {
* CreateCommentResponse: gql`
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
* [...]
* }`,
* },
* mutations: {
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* PostComment: ({variables, state}) => ({
* optimisticResponse: {
* [...]
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
* },
* queries: {
* EmbedQuery: {
* reducer: (previousResult, action, variables) => {
* return previousResult;
* },
* },
* },
* });
*/
add(extension) {
Object.keys(extension.fragments || []).forEach((key) => this.addFragment(key, extension.fragments[key]));
Object.keys(extension.mutations || []).forEach((key) => this.addMutationOptions(key, extension.mutations[key]));
Object.keys(extension.queries || []).forEach((key) => this.addQueryOptions(key, extension.queries[key]));
}
// The fragments and configs are lazily loaded to allow circular dependencies to work.
// TODO: We might want to change this to an explicit add after we have lazy Queries and Mutations.
let initialized = false;
/**
* Get a list of mutation options.
*/
getMutationOptions(key) {
return this.mutationOptions[key] || [];
}
function init() {
if (initialized) { return; }
initialized = true;
/**
* Get a list of query options.
*/
getQueryOptions(key) {
return this.queryOptions[key] || [];
}
// Add fragments from framework.
[globalFragments].forEach((map) =>
Object.keys(map).forEach((key) => addFragment(key, map[key]))
);
// Add configs from plugins.
getGraphQLExtensions().forEach((ext) => add(ext));
}
/**
* resolveFragments finds fragment spread names and attachs
* the related fragment document to the given root document.
*/
export function resolveFragments(document) {
if (document.loc.source) {
// Remember keys that we have already resolved.
const resolvedKeys = [];
// Spreads from slots that are empty and need to be removed.
// (works around the issue that we don't know the resource type
// if we don't have a fragment)
const spreadsToBeRemoved = [];
// fragments to be attached.
const subFragments = [];
// body contains the final result.
let body = document.loc.source.body;
let done = false;
while (!done) {
done = true;
const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || [];
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
.filter((key) => resolvedKeys.indexOf(key) === -1)
.forEach((key) => {
const doc = getFragmentDocument(key);
if (doc) {
subFragments.push(doc);
// We found a new fragment, so we are not done yet.
done = false;
} else if(key.startsWith('TalkSlot_')) {
spreadsToBeRemoved.push(key);
}
resolvedKeys.push(key);
});
body = mergeDocuments([body, ...subFragments]).loc.source.body;
/**
* getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource.
* It parses the slot name and the resource and usees the plugin API to assemble
* the fragment document.
*/
getSlotFragmentDocument(key) {
const match = key.match(/TalkSlot_(.*)_(.*)/);
if (!match) {
return '';
}
spreadsToBeRemoved.forEach((key) => {
const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g');
body = body.replace(regex, '');
});
const slot = match[1][0].toLowerCase() + match[1].substr(1);
const resource = match[2];
const documents = this.getSlotFragments(slot, resource);
return gql`${body}`;
} else {
console.warn('Can only resolve fragments from documents definied using the gql tag.');
if (documents.length === 0) {
return '';
}
const names = documents.map((d) => getDefinitionName(d));
const typeName = getTypeName(documents[0]);
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${typeName} {
...${names.join('\n...')}\n
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getRegistryFragmentDocument assembles a fragment document using
* all registered fragment under given `key`.
*/
getRegistryFragmentDocument(key) {
if (!(key in this.fragments)) {
return '';
}
let documents = this.fragments[key].documents;
let fields = `...${this.fragments[key].names.join('\n...')}\n`;
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${this.fragments[key].type} {
${fields}
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getFragmentDocument returns a fragment that assembles all registered
* fragments under given `key` or if `key` refers to Slot fragments it will
* return the slot fragments specified by this key.
*/
getFragmentDocument(key) {
return this.getRegistryFragmentDocument(key) || this.getSlotFragmentDocument(key);
}
/**
* resolveFragments finds fragment spread names and attachs
* the related fragment document to the given root document.
*/
resolveFragments(document) {
if (document.loc.source) {
// Remember keys that we have already resolved.
const resolvedKeys = [];
// Spreads from slots that are empty and need to be removed.
// (works around the issue that we don't know the resource type
// if we don't have a fragment)
const spreadsToBeRemoved = [];
// fragments to be attached.
const subFragments = [];
// body contains the final result.
let body = document.loc.source.body;
let done = false;
while (!done) {
done = true;
const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || [];
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
.filter((key) => resolvedKeys.indexOf(key) === -1)
.forEach((key) => {
const doc = this.getFragmentDocument(key);
if (doc) {
subFragments.push(doc);
// We found a new fragment, so we are not done yet.
done = false;
} else if(key.startsWith('TalkSlot_')) {
spreadsToBeRemoved.push(key);
}
resolvedKeys.push(key);
});
body = mergeDocuments([body, ...subFragments]).loc.source.body;
}
spreadsToBeRemoved.forEach((key) => {
const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g');
body = body.replace(regex, '');
});
return gql`${body}`;
} else {
console.warn('Can only resolve fragments from documents definied using the gql tag.');
}
return document;
}
return document;
}
/**
* createGraphQLRegistry
* @param {Function} getSlotFragments A callback with signature `(slot, part) => [documents]` to retrieve slot fragments.
* @return {Object} graphql registry
*/
export function createGraphQLRegistry(getSlotFragments) {
return new GraphQLRegistry(getSlotFragments);
}
@@ -0,0 +1,17 @@
import {browserHistory} from 'react-router';
import {useBasename} from 'history';
/**
* createHistory returns the history service for react router
* @param {string} basename base path of the url
* @return {Object} histor service
*/
export function createHistory(basename) {
if (!basename) {
return browserHistory;
}
return useBasename(() => browserHistory)({
basename
});
}
@@ -0,0 +1,18 @@
/**
* createNotificationService returns a notification services based on pym.
* @param {Object} pym
* @return {Object} notification service
*/
export function createNotificationService(pym) {
return {
success(msg) {
pym.sendMessage('coral-alert', `success|${msg}`);
},
error(msg) {
pym.sendMessage('coral-alert', `error|${msg}`);
},
info(msg) {
pym.sendMessage('coral-alert', `info|${msg}`);
},
};
}
+184
View File
@@ -0,0 +1,184 @@
import React from 'react';
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 {getDisplayName} from 'coral-framework/helpers/hoc';
import camelize from '../helpers/camelize';
import uuid from 'uuid/v4';
// This is returned for pluginConfig when it is empty.
const emptyConfig = {};
// 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) {
// Detect access from React DevTools and ignore those.
const error = new Error();
const accessFromDevTools = ['backend.js', 'dehydrate']
.every((keyword) => error.stack && error.stack.includes(keyword));
// Only care about the components defined in the plugins.
if (component.talkPluginName && !accessFromDevTools) {
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;
}
function addMetaDataToSlotComponents(plugins) {
// Add talkPluginName to Slot Components.
plugins.forEach((plugin) => {
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();
});
});
});
}
class PluginsService {
constructor(plugins) {
this.plugins = plugins;
addMetaDataToSlotComponents(plugins);
}
getSlotComponents(slot, reduxState, props = {}, queryData = {}) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return flatten(this.plugins
// Filter out components that have slots and have been disabled in `plugin_config`
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components))
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot])
)
.filter((component) => {
if(!component.isExcluded) {
return true;
}
let resolvedProps = this.getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
return !component.isExcluded(resolvedProps);
});
}
isSlotEmpty(slot, reduxState, props = {}, queryData = {}) {
return this.getSlotComponents(slot, reduxState, props, queryData).length === 0;
}
/**
* 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`.
*/
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.
*/
getSlotElements(slot, reduxState, props = {}, queryData = {}) {
return this.getSlotComponents(slot, reduxState, props, queryData)
.map((component, i) => {
return React.createElement(component, {key: i, ...this.getSlotComponentProps(component, reduxState, props, queryData)});
});
}
getSlotFragments(slot, part) {
const components = uniq(flattenDeep(this.plugins
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
.map((o) => o.module.slots[slot])
));
const documents = components
.map((c) => c.fragments)
.filter((fragments) => fragments && fragments[part])
.reduce((res, fragments) => {
res.push(fragments[part]);
return res;
}, []);
return documents;
}
getGraphQLExtensions() {
return this.plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => !isEmpty(o));
}
getModQueueConfigs() {
return merge(...this.plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
getTranslations() {
return this.plugins
.map((o) => o.module.translations)
.filter((o) => o);
}
getReducers() {
return merge(
...this.plugins
.filter((o) => o.module.reducer)
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
);
}
}
/**
* createPluginsService returns a plugins service.
* @param {Array} plugins config as returned from importing `pluginsConfig`
* @return {Object} plugins service
*/
export function createPluginsService(pluginsConfig) {
return new PluginsService(pluginsConfig);
}
+69
View File
@@ -0,0 +1,69 @@
import merge from 'lodash/merge';
const buildOptions = (inputOptions = {}) => {
const defaultOptions = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin'
};
const options = merge({}, defaultOptions, inputOptions);
if (options.method.toLowerCase() !== 'get') {
options.body = JSON.stringify(options.body);
}
return options;
};
const handleResp = (res) => {
if (res.status > 399) {
return res.json().then((err) => {
let message = err.message || res.status;
const error = new Error();
if (err.error && err.error.metadata && err.error.metadata.message) {
error.metadata = err.error.metadata.message;
}
if (err.error && err.error.translation_key) {
error.translation_key = err.error.translation_key;
}
error.message = message;
error.status = res.status;
throw error;
});
} else if (res.status === 204) {
return res.text();
} else {
return res.json();
}
};
/**
* createRestClient setups and returns a Rest Client
* @param {Object} options configuration
* @param {string} options.uri uri of the rest server
* @param {string|function} [options.token] auth token
* @return {Object} rest client
*/
export function createRestClient(options) {
const {token, uri} = options;
const client = (path, options) => {
const authToken = typeof token === 'function' ? token() : token;
let opts = options;
if (authToken) {
opts = merge({}, options, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
}
return fetch(`${uri}${path}`, buildOptions(opts)).then(handleResp);
};
client.uri = uri;
return client;
}
@@ -0,0 +1,43 @@
function getStorage(type) {
let storage = window[type], x = '__storage_test__';
try {
storage.setItem(x, x);
storage.removeItem(x);
} catch (e) {
const ignore = (
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0
);
if (!ignore) {
console.warning(e); // eslint-disable-line
return null;
}
}
return storage;
}
/**
* createStorage returns a localStorage wrapper if available
* @return {Object} localStorage wrapper
*/
export function createStorage() {
return getStorage('localStorage');
}
+12 -53
View File
@@ -1,68 +1,27 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {getClient} from './client';
let listeners = [];
export function injectReducers(reducers) {
const store = getStore();
store.coralReducers = {...store.coralReducers, ...reducers};
store.replaceReducer(combineReducers(store.coralReducers));
}
import {createStore as reduxCreateStore, combineReducers, applyMiddleware, compose} from 'redux';
/**
* Add a action listener to the redux store.
* The action is passed as the first argument to the callback.
* createStore creates a Redux Store
* @param {Object} reducers addtional reducers
* @param {Array} [middlewares] additional middlewares
* @return {Object} redux store
*/
export function addListener(cb) {
listeners.push(cb);
}
export function getStore() {
if (window.coralStore) {
return window.coralStore;
}
const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
const customListener = () => (next) => (action) => {
listeners.forEach((cb) => {cb(action);});
return next(action);
};
const middlewares = [
export function createStore(reducers, middlewares = []) {
const enhancers = [
applyMiddleware(
getClient().middleware(),
thunk,
apolloErrorReporter,
customListener,
...middlewares,
),
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
enhancers.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: getClient().reducer()
};
window.coralStore = createStore(
combineReducers(coralReducers),
return reduxCreateStore(
combineReducers(reducers),
{},
compose(...middlewares)
compose(...enhancers)
);
window.coralStore.coralReducers = coralReducers;
return window.coralStore;
}
@@ -1,37 +0,0 @@
import {createNetworkInterface} from 'apollo-client';
import {getAuthToken} from '../helpers/request';
import {BASE_PATH} from 'coral-framework/constants/url';
//==============================================================================
// NETWORK INTERFACE
//==============================================================================
const networkInterface = createNetworkInterface({
uri: `${BASE_PATH}api/v1/graph/ql`,
opts: {
credentials: 'same-origin'
}
});
//==============================================================================
// MIDDLEWARES
//==============================================================================
networkInterface.use([{
applyMiddleware(req, next) {
if (!req.options.headers) {
req.options.headers = {}; // Create the header object if needed.
}
let authToken = getAuthToken();
if (authToken) {
req.options.headers['authorization'] = `Bearer ${authToken}`;
}
next();
}
}]);
export {
networkInterface
};
+11 -17
View File
@@ -2,6 +2,7 @@ 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 * from 'coral-framework/helpers/strings';
export const getTotalActionCount = (type, comment) => {
return comment.action_summaries
@@ -147,25 +148,18 @@ export function forEachError(error, callback) {
});
}
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
if (dateA < dateB) { return -1; }
if (dateA > dateB) { return 1; }
return 0;
};
export function getErrorMessages(error) {
const result = [];
forEachError(error, ({msg}) => result.push(msg));
return result;
}
const descending = (a, b) => ascending(a, b) * -1;
export function appendNewNodes(nodesA, nodesB) {
return nodesA.concat(nodesB.filter((nodeB) => !nodesA.some((nodeA) => nodeA.id === nodeB.id)));
}
export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL') {
const added = nodes.concat(comments);
if (sortOrder === 'CHRONOLOGICAL') {
return added.sort(ascending);
}
if (sortOrder === 'REVERSE_CHRONOLOGICAL') {
return added.sort(descending);
}
throw new Error(`Unknown sort order ${sortOrder}`);
export function prependNewNodes(nodesA, nodesB) {
return nodesB.filter((nodeB) => !nodesA.some((nodeA) => nodeA.id === nodeB.id)).concat(nodesA);
}
export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which);
@@ -11,8 +11,11 @@ import NotLoggedIn from '../components/NotLoggedIn';
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';
// TODO: Auth logic needs refactoring.
import {showSignInDialog, checkLogin} from 'coral-embed-stream/src/actions/auth';
import {appendNewNodes} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
@@ -34,12 +37,12 @@ class ProfileContainer extends Component {
limit: 5,
cursor: this.props.root.me.comments.endCursor,
},
updateQuery: (previous, {fetchMoreResult:{comments}}) => {
updateQuery: (previous, {fetchMoreResult:{me: {comments}}}) => {
const updated = update(previous, {
me: {
comments: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'),
$apply: (nodes) => appendNewNodes(nodes, comments.nodes),
},
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
@@ -120,9 +123,11 @@ const CommentFragment = gql`
`;
const LOAD_MORE_QUERY = gql`
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Date) {
comments(query: {limit: $limit, cursor: $cursor}) {
...TalkSettings_CommentConnectionFragment
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Cursor) {
me {
comments(query: {limit: $limit, cursor: $cursor}) {
...TalkSettings_CommentConnectionFragment
}
}
}
${CommentFragment}
+8 -8
View File
@@ -12,11 +12,11 @@ export const name = 'talk-plugin-commentbox';
// Given a newly posted comment's status, show a notification to the user
// if needed
export const notifyForNewCommentStatus = (addNotification, status) => {
export const notifyForNewCommentStatus = (notify, status) => {
if (status === 'REJECTED') {
addNotification('error', t('comment_box.comment_post_banned_word'));
notify('error', t('comment_box.comment_post_banned_word'));
} else if (status === 'PREMOD') {
addNotification('success', t('comment_box.comment_post_notif_premod'));
notify('success', t('comment_box.comment_post_notif_premod'));
}
};
@@ -45,12 +45,12 @@ class CommentBox extends React.Component {
postComment,
assetId,
parentId,
addNotification,
notify,
currentUser,
} = this.props;
if (!can(currentUser, 'INTERACT_WITH_COMMUNITY')) {
addNotification('error', t('error.NOT_AUTHORIZED'));
notify('error', t('error.NOT_AUTHORIZED'));
return;
}
@@ -73,7 +73,7 @@ class CommentBox extends React.Component {
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach((hook) => hook(data));
notifyForNewCommentStatus(addNotification, postedComment.status);
notifyForNewCommentStatus(notify, postedComment.status);
if (commentPostedHandler) {
commentPostedHandler();
@@ -81,7 +81,7 @@ class CommentBox extends React.Component {
})
.catch((err) => {
this.setState({loadingState: 'error'});
forEachError(err, ({msg}) => addNotification('error', msg));
forEachError(err, ({msg}) => notify('error', msg));
});
}
@@ -186,7 +186,7 @@ CommentBox.propTypes = {
currentUser: PropTypes.object.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
addNotification: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
const mapStateToProps = ({commentBox}) => ({commentBox});

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