mirror of
https://github.com/wassname/talk.git
synced 2026-07-12 10:03:29 +08:00
Merge branch 'master' into feature/heroku-deploy
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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
@@ -9,6 +9,7 @@
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
"transform-react-jsx",
|
||||
"syntax-dynamic-import"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'});
|
||||
};
|
||||
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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});
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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'});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+7
-8
@@ -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) => {
|
||||
+41
-39
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+2
-1
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
// });
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user