Merge master

This commit is contained in:
Mendel Konikov
2018-04-10 20:10:21 -04:00
537 changed files with 14237 additions and 4489 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"presets": [
["es2015", {modules: false}]
["es2015", {"modules": false}]
],
"plugins": [
"transform-class-properties",
+35 -27
View File
@@ -16,7 +16,9 @@ integration_job: &integration_job
environment:
<<: *integration_environment
docker:
- image: circleci/node:8-browsers
# TODO: replace with node:8-browsers when build issues are resolved.
# - image: circleci/node:8-browsers
- image: coralproject/ci
- image: circleci/mongo:3
- image: circleci/redis:4-alpine
steps:
@@ -32,10 +34,14 @@ integration_job: &integration_job
- store_test_results:
when: always
path: /tmp/circleci-test-results
- store_artifacts:
when: always
path: /tmp/circleci-test-results
version: 2
jobs:
# npm_dependencies will install the dependencies used by all other steps.
# npm_dependencies will install the dependencies used by all other steps.
npm_dependencies:
<<: *job_defaults
steps:
@@ -56,7 +62,7 @@ jobs:
- persist_to_workspace:
root: .
paths: node_modules
# lint will perform file linting.
lint:
<<: *job_defaults
@@ -67,7 +73,7 @@ jobs:
- run:
name: Perform linting
command: yarn lint
# build_assets will build the static assets.
build_assets:
<<: *job_defaults
@@ -86,11 +92,11 @@ jobs:
- save_cache:
key: build-cache-{{ .Branch }}-{{ .Revision }}
paths:
- ./node_modules/.cache/hard-source
- ./node_modules/.cache/babel-loader
- persist_to_workspace:
root: .
paths: dist
# test_unit will run the unit tests.
test_unit:
<<: *job_defaults
@@ -102,7 +108,7 @@ jobs:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- run:
- run:
name: Setup the test results directory
command: mkdir -p /tmp/circleci-test-results
- run:
@@ -120,7 +126,7 @@ jobs:
- store_test_results:
when: always
path: /tmp/circleci-test-results
# test_integration_chrome_local will run the integration tests locally with
# chrome headless.
test_integration_chrome_local:
@@ -128,7 +134,7 @@ jobs:
environment:
<<: *integration_environment
E2E_BROWSERS: chrome
# test_integration_firefox_local will run the integration tests locally with
# firefox headless.
test_integration_firefox_local:
@@ -145,7 +151,7 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: chrome
# test_integration_firefox will run the integration tests with firefox in
# browserstack.
test_integration_firefox:
@@ -154,7 +160,7 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: firefox
# test_integration_edge will run the integration tests with edge in
# browserstack.
test_integration_edge:
@@ -163,7 +169,7 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: edge
# test_integration_ie will run the integration tests with ie in
# browserstack.
test_integration_ie:
@@ -174,7 +180,7 @@ jobs:
E2E_BROWSERS: ie
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# test_integration_safari will run the integration tests with safari in
# browserstack.
test_integration_safari:
@@ -185,7 +191,7 @@ jobs:
E2E_BROWSERS: safari
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# deploy will deploy the application as a docker image.
deploy:
<<: *job_defaults
@@ -236,13 +242,14 @@ workflows:
requires:
- npm_dependencies
- test_integration_chrome_local:
<<: *filter_develop
requires:
- build_assets
- test_integration_firefox_local:
<<: *filter_develop
requires:
- build_assets
# TODO: uncomment when more reliable
# - test_integration_firefox_local:
# <<: *filter_develop
# requires:
# - build_assets
deploy-tagged:
jobs:
- npm_dependencies:
@@ -271,14 +278,15 @@ workflows:
<<: *filter_deploy
requires:
- build_assets
- test_integration_ie:
<<: *filter_deploy
requires:
- build_assets
- test_integration_safari:
<<: *filter_deploy
requires:
- build_assets
# TODO: uncomment when more reliable
# - test_integration_ie:
# <<: *filter_deploy
# requires:
# - build_assets
# - test_integration_safari:
# <<: *filter_deploy
# requires:
# - build_assets
- deploy:
<<: *filter_deploy
requires:
@@ -289,4 +297,4 @@ workflows:
- test_integration_edge
# TODO: uncomment when more reliable
# - test_integration_ie
# - test_integration_safari
# - test_integration_safari
+1 -1
View File
@@ -23,7 +23,7 @@ if [[ "$BROWSERSTACK" == "true" && -n "$BROWSERSTACK_KEY" ]]; then
echo Testing on browserstack
node scripts/e2e.js --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS" --browserstack
else
# When browserstack is not available test locally using chrome headless.
# When browserstack is not available test locally.
echo Testing locally
node scripts/e2e.js --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS" --headless
fi
+1 -2
View File
@@ -1,6 +1,5 @@
**/*.html
dist
docs
node_modules
public
**/*.min.js
+9 -2
View File
@@ -6,6 +6,7 @@ npm-debug.log*
dump.rdb
client/coral-framework/graphql/introspection.json
docs/source/_data/introspection.json
.env
*.cfg
@@ -14,6 +15,7 @@ client/coral-framework/graphql/introspection.json
*.swp
*.DS_STORE
.prettierrc.json
.vscode
coverage/
test/e2e/reports/
@@ -22,13 +24,14 @@ test/e2e/selenium-debug.log
browserstack.err
plugins.json
plugins/*
plugins/*
!plugins/talk-plugin-akismet
!plugins/talk-plugin-auth
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-downvote
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-flag-details
@@ -43,23 +46,27 @@ plugins/*
!plugins/talk-plugin-notifications-category-featured
!plugins/talk-plugin-notifications-category-reply
!plugins/talk-plugin-notifications-category-staff
!plugins/talk-plugin-notifications-digest-daily
!plugins/talk-plugin-notifications-digest-hourly
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-sort-most-downvoted
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-respected
!plugins/talk-plugin-sort-most-upvoted
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-upvote
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-rich-text
!plugins/talk-plugin-rich-text-pell
**/node_modules/*
yarn-error.log
+1 -1
View File
@@ -1,6 +1,6 @@
{
"exec": "npm-run-all --parallel generate-introspection start:development",
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client", "docs/*"],
"ext": "js,json,graphql,yml",
"watch": [
".",
+3 -3
View File
@@ -54,12 +54,12 @@ We are looking for _documentarians_ to:
* take the lead in making sections, or the over all structure better.
Our documentation is stored in markdown files in the [docs](docs) directory. We
use Jekyll to provide our docs. To preview:
use [Hexo](https://hexo.io/) to provide our docs. To preview:
```shell
cd docs
bundle install
bundle exec jekyll serve
yarn
yarn start
```
Then visit http://127.0.0.1:4000/talk/.
+1
View File
@@ -18,5 +18,6 @@ ONBUILD COPY . /usr/src/app
# clear out the development dependencies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN cli plugins reconcile && \
yarn && \
yarn build && \
yarn cache clean
+8 -4
View File
@@ -12,19 +12,23 @@ You're just one click away from trying Talk - all you need is a Heroku account a
## Technical Documentation
From getting up and running, to advanced configuration, to how to scale Talk, our [Talk Technical Docs](https://coralproject.github.io/talk/) have everything you need to know.
From getting up and running, to advanced configuration, to how to scale Talk, our [Talk Technical Docs](https://docs.coralproject.net/talk/) have everything you need to know.
## Product Guide
Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https://coralproject.github.io/talk/how-talk-works).
Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https://docs.coralproject.net/talk/how-talk-works).
## Relevant Links
## Pre-Launch Guide
Youve installed Talk on your server, and youre preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://blog.coralproject.net/youve-installed-talk-now-what/).
## More Resources
- [Talk Product Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
- [Our Blog](https://blog.coralproject.net/)
- [Community Forums](https://community.coralproject.net/)
- [Community Guides for Journalism](https://guides.coralproject.net/)
- [More About Us](https://coralproject.net/)
- [Talk Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
## End-to-End Testing
+19 -13
View File
@@ -13,6 +13,7 @@ const CommentModel = require('../models/comment');
const AssetsService = require('../services/assets');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const Context = require('../graph/context');
const inquirer = require('inquirer');
const { URL } = require('url');
@@ -52,22 +53,27 @@ async function refreshAssets(ageString) {
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
let assets = await AssetModel.find({
$or: [
{
scraped: {
$lte: age,
let assets = await AssetModel.find(
{
$or: [
{
scraped: {
$lte: age,
},
},
},
{
scraped: null,
},
],
});
{
scraped: null,
},
],
},
{ id: 1 }
);
// Create a graph context.
const ctx = Context.forSystem();
// Queue all the assets for scraping.
await Promise.all(assets.map(scraper.create));
await Promise.all(assets.map(({ id }) => scraper.create(ctx, id)));
console.log('Assets were queued to be scraped');
util.shutdown();
} catch (e) {
+7 -1
View File
@@ -10,6 +10,12 @@ const serve = require('../serve');
program
.option('-j, --jobs', 'enable job processing on this thread')
.option(
'--disabled-jobs <jobs>',
'disable jobs specified if the -j option is passed, specified as a comma separated list',
val => val.split(','),
[]
)
.option(
'-w, --websockets',
'enable the websocket (subscriptions) handler on this thread'
@@ -17,7 +23,7 @@ program
.parse(process.argv);
// Start serving.
serve({ jobs: program.jobs, websockets: program.websockets }).catch(err => {
serve(program).catch(err => {
console.error(err);
util.shutdown(1);
});
+3 -1
View File
@@ -15,6 +15,7 @@ const SetupService = require('../services/setup');
const UsersService = require('../services/users');
const MigrationService = require('../services/migration');
const errors = require('../errors');
const Context = require('../graph/context');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
@@ -184,7 +185,8 @@ const performSetup = async () => {
},
]);
let { user: newUser } = await SetupService.setup({
const ctx = Context.forSystem();
let { user: newUser } = await SetupService.setup(ctx, {
settings: settings.toObject(),
user: {
email: user.email,
+69 -75
View File
@@ -7,8 +7,6 @@
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const { graphql } = require('graphql');
const helpers = require('../services/migration/helpers');
const { stripIndent } = require('common-tags');
const Table = require('cli-table');
@@ -21,31 +19,15 @@ inquirer.registerPrompt(
require('inquirer-autocomplete-prompt')
);
const schema = require('../graph/schema');
const Context = require('../graph/context');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const CommentModel = require('../models/comment');
const ActionModel = require('../models/action');
const USER_ROLES = require('../models/enum/user_roles');
const mongoose = require('../services/mongoose');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
/**
* transforms a specific action to a removal action on the target model.
*/
const actionDecrTransformer = ({ item_id, action_type, group_id }) => ({
query: { id: item_id },
update: {
$inc: {
[`action_counts.${action_type.toLowerCase()}`]: -1,
[`action_counts.${action_type.toLowerCase()}_${group_id.toLowerCase()}`]: -1,
},
},
});
/**
* Deletes a user and cleans up their associated verifications.
*/
@@ -76,66 +58,29 @@ async function deleteUser(userID) {
return util.shutdown();
}
const { transformSingleWithCursor } = helpers({
queryBatchSize: 10000,
updateBatchSize: 10000,
});
const ctx = Context.forSystem();
console.warn("Removing user's actions");
// Remove all actions against comments.
await transformSingleWithCursor(
ActionModel.collection.find({ user_id: user.id, item_type: 'COMMENTS' }),
actionDecrTransformer,
CommentModel
const { data, errors } = await ctx.graphql(
`
mutation DeleteUser($user_id: ID!) {
delUser(id: $user_id) {
errors {
translation_key
}
}
}
`,
{ user_id: user.id }
);
if (errors) {
throw errors;
}
// Remove all actions against users.
await transformSingleWithCursor(
ActionModel.collection.find({ user_id: user.id, item_type: 'USERS' }),
actionDecrTransformer,
UserModel
);
if (data.errors) {
throw data.errors;
}
// Remove all the user's actions.
await ActionModel.where({ user_id: user.id })
.setOptions({ multi: true })
.remove();
console.warn("Removing user's comments");
// Removes all the user's reply counts on each of the comments that they
// have commented on.
await transformSingleWithCursor(
CommentModel.collection.aggregate([
{ $match: { author_id: user.id } },
{
$group: {
_id: '$parent_id',
count: { $sum: 1 },
},
},
]),
({ _id: parent_id, count }) => ({
query: { id: parent_id },
update: {
$inc: {
reply_count: -1 * count,
},
},
}),
CommentModel
);
// Remove all the user's comments.
await CommentModel.where({ author_id: user.id })
.setOptions({ multi: true })
.remove();
console.warn('Removing the user');
// Remove the user.
await user.remove();
console.log('User was deleted.');
util.shutdown();
} catch (err) {
@@ -197,7 +142,7 @@ async function searchUsers() {
value = '';
}
const { data, errors } = await graphql(schema, searchQuery, {}, ctx, {
const { data, errors } = await ctx.graphql(searchQuery, {
value,
});
if (errors && errors.length > 0) {
@@ -312,10 +257,59 @@ async function verifyUserEmail(userID, email) {
}
}
/**
* createUser will prompt the user for the user information when creating a
* local user.
*/
async function createUser() {
try {
const answers = await inquirer.prompt([
{
name: 'email',
message: 'Email',
},
{
name: 'username',
message: 'Username',
},
{
name: 'password',
message: 'Password',
type: 'password',
},
{
name: 'role',
message: 'Role',
type: 'list',
choices: USER_ROLES,
},
]);
const { email, username, password, role } = answers;
// Create the user.
const user = await UsersService.createLocalUser(email, password, username);
// Set the role.
await UsersService.setRole(user.id, role);
console.log(`Created User[${user.id}]`);
util.shutdown(0);
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('create')
.description('creates a local user')
.action(createUser);
program
.command('delete <userID>')
.description('delete a user')
@@ -12,7 +12,7 @@ class MyPluginComponent extends React.Component {
<small>
To read more about plugins check{' '}
<a href="https://coralproject.github.io/talk/plugins-client.html">
<a href="https://docs.coralproject.net/talk/plugins-client">
our docs and guides!
</a>
</small>
+1 -1
View File
@@ -13,7 +13,7 @@
};
```
To read more info on how to build client plugins. Please, go to: https://coralproject.github.io/talk/plugins-client.html
To read more info on how to build client plugins. Please, go to: https://docs.coralproject.net/talk/plugins-client
*/
import MyPluginComponent from './components/MyPluginComponent';
+5 -8
View File
@@ -2,6 +2,7 @@
require('../services/env');
const debug = require('debug')('talk:util');
const { uniq } = require('lodash');
const util = (module.exports = {});
@@ -23,11 +24,7 @@ util.shutdown = (defaultCode = 0, signal = null) => {
debug(`${util.toshutdown.length} jobs now being called`);
Promise.all(
util.toshutdown
.map(func => (func ? func(signal) : null))
.filter(func => func)
)
Promise.all(util.toshutdown.map(func => (func ? func(signal) : null)))
.then(() => {
debug('Shutdown complete, now exiting');
process.exit(defaultCode);
@@ -49,14 +46,14 @@ util.onshutdown = jobs => {
debug(`${jobs.length} jobs registered to be called during shutdown`);
// Add the new jobs to shutdown to the object reference.
util.toshutdown = util.toshutdown.concat(jobs);
util.toshutdown = uniq(util.toshutdown.concat(jobs));
};
// Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the
// event that we have an external event. SIGUSR2 is called when the app is asked
// to be 'killed', same procedure here.
process.on('SIGTERM', () => util.shutdown(0, 'SIGTERM'));
process.on('SIGINT', () => util.shutdown(0, 'SIGINT'));
process.once('SIGTERM', () => util.shutdown(0, 'SIGTERM'));
process.once('SIGINT', () => util.shutdown(0, 'SIGINT'));
process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2'));
// Makes the script crash on unhandled rejections instead of silently
+14 -2
View File
@@ -2,10 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Router, Route, IndexRedirect, IndexRoute } from 'react-router';
import Configure from 'routes/Configure';
import Install from 'routes/Install';
import Stories from 'routes/Stories';
import Community from 'routes/Community';
import Configure from 'routes/Configure';
import StreamSettings from './routes/Configure/containers/StreamSettings';
import ModerationSettings from './routes/Configure/containers/ModerationSettings';
import TechSettings from './routes/Configure/containers/TechSettings';
import { ModerationLayout, Moderation } from 'routes/Moderation';
import Layout from 'containers/Layout';
@@ -15,7 +20,14 @@ const routes = (
<Route exact path="/admin/install" component={Install} />
<Route path="/admin" component={Layout}>
<IndexRedirect to="/admin/moderate" />
<Route path="configure" component={Configure} />
<Route path="configure" component={Configure}>
<Route path="stream" component={StreamSettings} />
<Route path="moderation" component={ModerationSettings} />
<Route path="tech" component={TechSettings} />
<IndexRedirect to="stream" />
</Route>
<Route path="stories" component={Stories} />
{/* Community Routes */}
+6 -2
View File
@@ -8,6 +8,10 @@ export const clearPending = () => {
return { type: actions.CLEAR_PENDING };
};
export const setActiveSection = section => {
return { type: actions.SET_ACTIVE_SECTION, section };
export const showSaveDialog = () => {
return { type: actions.SHOW_SAVE_DIALOG };
};
export const hideSaveDialog = () => {
return { type: actions.HIDE_SAVE_DIALOG };
};
@@ -14,7 +14,7 @@ const buildUserHistory = (userState = {}) => {
return orderBy(
flatten(
Object.keys(userState.status)
.filter(k => k !== '__typename')
.filter(k => !k.startsWith('__'))
.map(k => userState.status[k].history)
),
'created_at',
@@ -25,37 +25,29 @@ class CommentDetails extends Component {
};
render() {
const { data, root, comment, clearHeightCache } = this.props;
const { root, comment, clearHeightCache } = this.props;
const { showDetail } = this.state;
const queryData = {
const slotPassthrough = {
clearHeightCache,
root,
comment,
more: showDetail,
};
return (
<div className={styles.root}>
<IfSlotIsNotEmpty
queryData={queryData}
slot={['adminCommentMoreDetails', 'adminCommentMoreFlagDetails']}
passthrough={slotPassthrough}
>
<a onClick={this.toggleDetail} className={styles.moreDetail}>
{showDetail ? t('modqueue.less_detail') : t('modqueue.more_detail')}
</a>
</IfSlotIsNotEmpty>
<Slot
fill="adminCommentDetailArea"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
more={showDetail}
/>
<Slot fill="adminCommentDetailArea" passthrough={slotPassthrough} />
{showDetail && (
<Slot
fill="adminCommentMoreDetails"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
<Slot fill="adminCommentMoreDetails" passthrough={slotPassthrough} />
)}
</div>
);
@@ -63,7 +55,6 @@ class CommentDetails extends Component {
}
CommentDetails.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
clearHeightCache: PropTypes.func,
@@ -1,109 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { matchLinks } from '../utils';
import memoize from 'lodash/memoize';
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
// generate a regulare expression that catches the `phrases`.
function generateRegExp(phrases) {
const inner = phrases
.map(phrase =>
phrase
.split(/\s+/)
.map(word => escapeRegExp(word))
.join('[\\s"?!.]+')
)
.join('|');
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, 'iu');
} catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, 'i');
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
function getPhrasesRegexp(suspectWords, bannedWords) {
return generateRegExp([...suspectWords, ...bannedWords]);
}
// Memoized version as arguments rarely change.
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
// an array of React Elements.
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = body.split(regexp);
return tokens.map(
(token, i) =>
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
);
}
// markLinks looks for links inside `body` and highlights them by returning
// an array of React Elements.
function markLinks(body) {
const matches = matchLinks(body);
const content = [];
let index = 0;
if (matches) {
matches.forEach((match, i) => {
content.push(body.substring(index, match.index));
content.push(<mark key={i}>{match.text}</mark>);
index = match.lastIndex;
});
}
content.push(body.substring(index));
return content;
}
const CommentFormatter = ({
body,
suspectWords,
bannedWords,
className = 'comment',
...rest
}) => {
// Breaking the body by line break
const textbreaks = body.split('\n');
return (
<span className={`${className}-text`} {...rest}>
{textbreaks.map((line, i) => {
const content = markLinks(line).map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
return (
<span key={i} className={`${className}-line`}>
{content}
{i !== textbreaks.length - 1 && (
<br className={`${className}-linebreak`} />
)}
</span>
);
})}
</span>
);
};
CommentFormatter.propTypes = {
className: PropTypes.string,
bannedWords: PropTypes.array,
suspectWords: PropTypes.array,
body: PropTypes.string,
};
export default CommentFormatter;
@@ -43,6 +43,9 @@ const CommentLabels = ({
comment,
comment: { className, status, actions, hasParent },
}) => {
const slotPassthrough = {
comment,
};
return (
<div className={cn(className, styles.root)}>
<div className={styles.coreLabels}>
@@ -69,7 +72,7 @@ const CommentLabels = ({
<Slot
className={styles.slot}
fill="adminCommentLabels"
queryData={{ comment }}
passthrough={slotPassthrough}
/>
</div>
);
@@ -0,0 +1,8 @@
.container {
max-width: 1280px;
margin: 0 auto;
}
.copy {
padding: 20px 0;
}
@@ -0,0 +1,13 @@
import React from 'react';
import styles from './Forbidden.css';
const Forbidden = () => (
<div className={styles.container}>
<p className={styles.copy}>
This page is for team use only. Please contact an administrator if you
want to join this team.
</p>
</div>
);
export default Forbidden;
@@ -1,5 +1,5 @@
import React from 'react';
import { matchLinks } from '../utils';
import matchLinks from 'coral-framework/utils/matchLinks';
export default ({ text, children }) => {
const hasLinks = !!matchLinks(text);
+3 -1
View File
@@ -1,4 +1,6 @@
.layout {
margin: 0 auto;
background-color: #FAFAFA;
}
height: inherit;
min-height: calc(100vh - 58px);
}
@@ -98,7 +98,6 @@ class UserDetail extends React.Component {
renderLoaded() {
const {
data,
root,
root: { me, user, totalComments, rejectedComments },
activeTab,
@@ -123,6 +122,11 @@ class UserDetail extends React.Component {
const banned = isBanned(user);
const suspended = isSuspended(user);
const slotPassthrough = {
root,
user,
};
return (
<ClickOutside onClickOutside={modal ? null : hideUserDetail}>
<Drawer
@@ -244,11 +248,7 @@ class UserDetail extends React.Component {
</ul>
</div>
<Slot
fill="userProfile"
data={this.props.data}
queryData={{ root, user }}
/>
<Slot fill="userProfile" passthrough={slotPassthrough} />
<hr />
@@ -301,7 +301,6 @@ class UserDetail extends React.Component {
<UserDetailCommentList
user={user}
root={root}
data={data}
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
@@ -320,7 +319,6 @@ class UserDetail extends React.Component {
<UserDetailCommentList
user={user}
root={root}
data={data}
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
@@ -368,9 +366,7 @@ UserDetail.propTypes = {
bulkReject: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.shape({
refetch: PropTypes.func.isRequired,
}),
data: PropTypes.object,
activeTab: PropTypes.string.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
@@ -5,7 +5,7 @@ import { Link } from 'react-router';
import { Icon } from 'coral-ui';
import CommentDetails from './CommentDetails';
import styles from './UserDetailComment.css';
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
import AdminCommentContent from 'coral-framework/components/AdminCommentContent';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import CommentAnimatedEdit from './CommentAnimatedEdit';
@@ -32,13 +32,12 @@ class UserDetailComment extends React.Component {
selected,
toggleSelect,
className,
data,
root: { settings },
} = this.props;
const queryData = { root, comment };
const formatterSettings = {
const slotPassthrough = {
root,
comment,
suspectWords: settings.wordlist.suspect,
bannedWords: settings.wordlist.banned,
body: comment.body,
@@ -76,7 +75,8 @@ class UserDetailComment extends React.Component {
</div>
</div>
<div className={styles.story}>
Story: {comment.asset.title}
{t('common.story')}:{' '}
{comment.asset.title ? comment.asset.title : comment.asset.url}
{
<Link to={`/admin/moderate/${comment.asset.id}`}>
{t('modqueue.moderate')}
@@ -88,15 +88,13 @@ class UserDetailComment extends React.Component {
<div className={styles.body}>
<Slot
fill="userDetailCommentContent"
data={data}
className={cn(
styles.commentContent,
'talk-admin-user-detail-comment'
)}
queryData={queryData}
slotSize={1}
defaultComponent={CommentFormatter}
{...formatterSettings}
size={1}
defaultComponent={AdminCommentContent}
passthrough={slotPassthrough}
/>
<a
className={styles.external}
@@ -109,6 +107,7 @@ class UserDetailComment extends React.Component {
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
<span className={styles.hasLinks}>
{/* TODO: translate string */}
<Icon name="error_outline" /> Contains Link
</span>
</IfHasLink>
@@ -128,7 +127,7 @@ class UserDetailComment extends React.Component {
</div>
</CommentAnimatedEdit>
</div>
<CommentDetails data={data} root={root} comment={comment} />
<CommentDetails root={root} comment={comment} />
</li>
);
}
@@ -136,7 +135,6 @@ class UserDetailComment extends React.Component {
UserDetailComment.propTypes = {
selected: PropTypes.bool,
data: PropTypes.object,
user: PropTypes.object.isRequired,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
@@ -9,7 +9,6 @@ import ApproveButton from './ApproveButton';
const UserDetailCommentList = props => {
const {
data,
root,
root: { user, comments: { nodes, hasNextPage } },
acceptComment,
@@ -70,7 +69,6 @@ const UserDetailCommentList = props => {
key={comment.id}
user={user}
root={root}
data={data}
comment={comment}
acceptComment={acceptComment}
rejectComment={rejectComment}
@@ -93,7 +91,6 @@ UserDetailCommentList.propTypes = {
root: PropTypes.object.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
@@ -2,4 +2,6 @@ const prefix = 'TALK_ADMIN_CONFIGURE';
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
export const SET_ACTIVE_SECTION = `${prefix}_SET_ACTIVE_SECTION`;
export const SHOW_SAVE_DIALOG = `${prefix}_SHOW_SAVE_DIALOG`;
export const HIDE_SAVE_DIALOG = `${prefix}_HIDE_SAVE_DIALOG`;
+2 -4
View File
@@ -11,6 +11,7 @@ import { logout } from 'coral-framework/actions/auth';
import { can } from 'coral-framework/services/perms';
import UserDetail from 'coral-admin/src/containers/UserDetail';
import PropTypes from 'prop-types';
import Forbidden from '../components/Forbidden';
class LayoutContainer extends React.Component {
render() {
@@ -47,10 +48,7 @@ class LayoutContainer extends React.Component {
} else {
return (
<Layout {...this.props} handleLogout={logout}>
<p>
This page is for team use only. Please contact an administrator if
you want to join this team.
</p>
<Forbidden />
</Layout>
);
}
+15 -6
View File
@@ -6,11 +6,23 @@ const initialState = {
canSave: false,
pending: {},
errors: {},
activeSection: 'stream',
saveDialog: false,
};
export default function configure(state = initialState, action) {
switch (action.type) {
case actions.SHOW_SAVE_DIALOG: {
return {
...state,
saveDialog: true,
};
}
case actions.HIDE_SAVE_DIALOG: {
return {
...state,
saveDialog: false,
};
}
case actions.UPDATE_PENDING: {
let next = state;
if (action.updater) {
@@ -40,11 +52,8 @@ export default function configure(state = initialState, action) {
pending: {},
canSave: false,
};
case actions.SET_ACTIVE_SECTION:
return {
...state,
activeSection: action.section,
};
default:
return state;
}
return state;
}
@@ -130,3 +130,12 @@ th.header:nth-child(2), th.header:nth-child(3) {
.loadMore {
margin-top: 24px;
}
.roleDropdown {
width: 150px;
}
.roleOption {
min-width: 100px;
}
@@ -200,24 +200,31 @@ class People extends React.Component {
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-role"
className={cn(
'talk-admin-community-people-dd-role',
styles.roleDropdown
)}
value={user.role}
placeholder={t('community.role')}
onChange={role => setUserRole(user.id, role)}
>
<Option
className={styles.roleOption}
value={COMMENTER}
label={t('community.commenter')}
/>
<Option
className={styles.roleOption}
value={STAFF}
label={t('community.staff')}
/>
<Option
className={styles.roleOption}
value={MODERATOR}
label={t('community.moderator')}
/>
<Option
className={styles.roleOption}
value={ADMIN}
label={t('community.admin')}
/>
@@ -1,50 +1,37 @@
import React, { Component } from 'react';
import { Button, List, Item } from 'coral-ui';
import styles from './Configure.css';
import StreamSettings from '../containers/StreamSettings';
import ModerationSettings from '../containers/ModerationSettings';
import TechSettings from '../containers/TechSettings';
import t from 'coral-framework/services/i18n';
import { can } from 'coral-framework/services/perms';
import React from 'react';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import { Button, List, Item } from 'coral-ui';
import { can } from 'coral-framework/services/perms';
import styles from './Configure.css';
import SaveChangesDialog from './SaveChangesDialog';
export default class Configure extends Component {
getSectionComponent(section) {
switch (section) {
case 'stream':
return StreamSettings;
case 'moderation':
return ModerationSettings;
case 'tech':
return TechSettings;
}
throw new Error(`Unknown section ${section}`);
}
class Configure extends React.Component {
render() {
const {
currentUser,
canSave,
savePending,
setActiveSection,
activeSection,
} = this.props;
const SectionComponent = this.getSectionComponent(activeSection);
const { canSave, currentUser, root, savePending, settings } = this.props;
if (!can(currentUser, 'UPDATE_CONFIG')) {
return (
<p>
You must be an administrator to access config settings. Please find
the nearest Admin and ask them to level you up!
</p>
);
return <p>{t('configure.access_message')}</p>;
}
const passProps = {
root,
settings,
};
return (
<div className={styles.container}>
<SaveChangesDialog
saveDialog={this.props.saveDialog}
hideSaveDialog={this.props.hideSaveDialog}
saveChanges={this.props.saveChanges}
discardChanges={this.props.discardChanges}
/>
<div className={styles.leftColumn}>
<List onChange={setActiveSection} activeItem={activeSection}>
<List
onChange={this.props.handleSectionChange}
activeItem={this.props.activeSection}
>
<Item itemId="stream" icon="speaker_notes">
{t('configure.stream_settings')}
</Item>
@@ -74,11 +61,7 @@ export default class Configure extends Component {
</div>
</div>
<div className={styles.mainContent}>
<SectionComponent
data={this.props.data}
root={this.props.root}
settings={this.props.settings}
/>
{React.cloneElement(this.props.children, passProps)}
</div>
</div>
);
@@ -87,11 +70,17 @@ export default class Configure extends Component {
Configure.propTypes = {
savePending: PropTypes.func.isRequired,
saveChanges: PropTypes.func.isRequired,
discardChanges: PropTypes.func.isRequired,
currentUser: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
setActiveSection: PropTypes.func.isRequired,
handleSectionChange: PropTypes.func.isRequired,
activeSection: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
saveDialog: PropTypes.bool,
hideSaveDialog: PropTypes.func.isRequired,
};
export default Configure;
@@ -52,7 +52,7 @@ class ModerationSettings extends React.Component {
};
render() {
const { settings, data, root, updatePending, errors } = this.props;
const { settings, slotPassthrough } = this.props;
return (
<ConfigurePage title={t('configure.moderation_settings')}>
@@ -82,13 +82,7 @@ class ModerationSettings extends React.Component {
suspectWords={settings.wordlist.suspect}
onChangeWordlist={this.updateWordlist}
/>
<Slot
fill="adminModerationSettings"
data={data}
queryData={{ root, settings }}
updatePending={updatePending}
errors={errors}
/>
<Slot fill="adminModerationSettings" passthrough={slotPassthrough} />
</ConfigurePage>
);
}
@@ -97,9 +91,8 @@ class ModerationSettings extends React.Component {
ModerationSettings.propTypes = {
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
slotPassthrough: PropTypes.object.isRequired,
};
export default ModerationSettings;
@@ -0,0 +1,40 @@
.buttonActions {
padding-top: 15px;
text-align: right;
}
.dialog {
padding: 25px;
min-width: 400px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
}
.title {
font-size: 18px;
font-weight: 800;
margin-bottom: 20px;
}
.cancel {
color: #363636;
margin-right: 15px;
display: inline-block;
&:hover {
cursor: pointer;
}
}
.button {
margin-left: 5px;
}
@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button, Dialog } from 'coral-ui';
import styles from './SaveChangesDialog.css';
import t from 'coral-framework/services/i18n';
const SaveChangesDialog = ({
saveDialog,
hideSaveDialog,
saveChanges,
discardChanges,
}) => (
<Dialog
className={cn(styles.dialog, 'talk-admin-configure-save-dialog')}
id="saveDialog"
open={saveDialog}
onCancel={hideSaveDialog}
>
<span className={styles.close} onClick={hideSaveDialog}>
×
</span>
<div className={styles.title}>
{t('configure.save_changes_dialog.unsaved_changes')}
</div>
{t('configure.save_changes_dialog.copy')}
<div
className={cn(
styles.buttonActions,
'talk-admin-configure-save-dialog-button-actions'
)}
>
<a className={styles.cancel} onClick={hideSaveDialog}>
Cancel
</a>
<Button onClick={discardChanges} className={styles.button}>
{t('configure.save_changes_dialog.discard')}
</Button>
<Button onClick={saveChanges} cStyle="green" className={styles.button}>
{t('configure.save_changes_dialog.save_settings')}
</Button>
</div>
</Dialog>
);
SaveChangesDialog.propTypes = {
saveDialog: PropTypes.bool.isRequired,
hideSaveDialog: PropTypes.func.isRequired,
saveChanges: PropTypes.func.isRequired,
discardChanges: PropTypes.func.isRequired,
};
export default SaveChangesDialog;
@@ -107,7 +107,7 @@ class StreamSettings extends React.Component {
};
render() {
const { settings, data, root, errors, updatePending } = this.props;
const { settings, slotPassthrough, errors } = this.props;
return (
<ConfigurePage title={t('configure.stream_settings')}>
@@ -220,13 +220,7 @@ class StreamSettings extends React.Component {
</div>
</ConfigureCard>
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
<Slot
fill="adminStreamSettings"
data={data}
queryData={{ root, settings }}
updatePending={updatePending}
errors={errors}
/>
<Slot fill="adminStreamSettings" passthrough={slotPassthrough} />
</ConfigurePage>
);
}
@@ -235,9 +229,8 @@ class StreamSettings extends React.Component {
StreamSettings.propTypes = {
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
slotPassthrough: PropTypes.object.isRequired,
};
export default StreamSettings;
@@ -34,7 +34,7 @@ class TechSettings extends React.Component {
};
render() {
const { settings, data, root, errors, updatePending } = this.props;
const { settings, slotPassthrough } = this.props;
return (
<ConfigurePage title={t('configure.tech_settings')}>
<Domainlist
@@ -50,13 +50,7 @@ class TechSettings extends React.Component {
onChange={this.updateCustomCssUrl}
/>
</ConfigureCard>
<Slot
fill="adminTechSettings"
data={data}
queryData={{ root, settings }}
updatePending={updatePending}
errors={errors}
/>
<Slot fill="adminTechSettings" passthrough={slotPassthrough} />
</ConfigurePage>
);
}
@@ -64,10 +58,9 @@ class TechSettings extends React.Component {
TechSettings.propTypes = {
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
slotPassthrough: PropTypes.object.isRequired,
errors: PropTypes.object,
};
export default TechSettings;
@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
@@ -10,15 +10,70 @@ import { getDefinitionName } from 'coral-framework/utils';
import StreamSettings from './StreamSettings';
import TechSettings from './TechSettings';
import ModerationSettings from './ModerationSettings';
import { clearPending, setActiveSection } from '../../../actions/configure';
import {
clearPending,
showSaveDialog,
hideSaveDialog,
} from '../../../actions/configure';
import Configure from '../components/Configure';
import { withRouter } from 'react-router';
class ConfigureContainer extends React.Component {
state = { nextRoute: '' };
class ConfigureContainer extends Component {
savePending = async () => {
await this.props.updateSettings(this.props.pending);
this.props.clearPending();
};
saveChanges = async () => {
await this.savePending();
this.props.hideSaveDialog();
this.gotoNextRoute();
};
discardChanges = async () => {
await this.props.clearPending();
this.props.hideSaveDialog();
this.gotoNextRoute();
};
gotoNextRoute = () => {
const { nextRoute } = this.state;
if (nextRoute) {
this.props.router.push(nextRoute);
this.setState({ nextRoute: '' });
}
};
handleSectionChange = async section => {
const nextRoute = `/admin/configure/${section}`;
if (this.shouldShowSaveDialog()) {
await this.setState({ nextRoute });
this.props.showSaveDialog();
} else {
// Just go to the section
this.props.router.push(nextRoute);
}
};
shouldShowSaveDialog = () => {
return !!Object.keys(this.props.pending).length;
};
routeLeave = ({ pathname }) => {
if (this.shouldShowSaveDialog()) {
this.setState({ nextRoute: pathname });
this.props.showSaveDialog();
return false;
}
};
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave);
}
render() {
if (this.props.data.error) {
return <div>{this.props.data.error.message}</div>;
@@ -30,15 +85,20 @@ class ConfigureContainer extends Component {
return (
<Configure
saveChanges={this.saveChanges}
discardChanges={this.discardChanges}
saveDialog={this.props.saveDialog}
activeSection={this.props.routes[3].path}
hideSaveDialog={this.props.hideSaveDialog}
canSave={this.props.canSave}
currentUser={this.props.currentUser}
data={this.props.data}
root={this.props.root}
settings={this.props.mergedSettings}
canSave={this.props.canSave}
handleSectionChange={this.handleSectionChange}
savePending={this.savePending}
setActiveSection={this.props.setActiveSection}
activeSection={this.props.activeSection}
/>
>
{this.props.children}
</Configure>
);
}
}
@@ -75,18 +135,21 @@ const mapStateToProps = state => ({
pending: state.configure.pending,
canSave: state.configure.canSave,
activeSection: state.configure.activeSection,
saveDialog: state.configure.saveDialog,
});
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
clearPending,
setActiveSection,
showSaveDialog,
hideSaveDialog,
},
dispatch
);
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
withUpdateSettings,
withConfigureQuery,
@@ -94,14 +157,20 @@ export default compose(
)(ConfigureContainer);
ConfigureContainer.propTypes = {
activeSection: PropTypes.string,
updateSettings: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
setActiveSection: PropTypes.func.isRequired,
showSaveDialog: PropTypes.func.isRequired,
hideSaveDialog: PropTypes.func.isRequired,
saveDialog: PropTypes.bool.isRequired,
currentUser: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
pending: PropTypes.object.isRequired,
mergedSettings: PropTypes.object.isRequired,
activeSection: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
router: PropTypes.object,
route: PropTypes.object,
routes: PropTypes.array,
};
@@ -5,6 +5,7 @@ import ModerationSettings from '../components/ModerationSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import { updatePending } from '../../../actions/configure';
import { mapProps } from 'recompose';
const slots = ['adminModerationSettings'];
@@ -41,5 +42,17 @@ export default compose(
}
`,
}),
connect(mapStateToProps, mapDispatchToProps)
connect(mapStateToProps, mapDispatchToProps),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
settings,
updatePending,
errors,
},
updatePending,
settings,
errors,
...rest,
}))
)(ModerationSettings);
@@ -5,6 +5,7 @@ import StreamSettings from '../components/StreamSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import { updatePending } from '../../../actions/configure';
import { mapProps } from 'recompose';
const slots = ['adminStreamSettings'];
@@ -42,5 +43,17 @@ export default compose(
}
`,
}),
connect(mapStateToProps, mapDispatchToProps)
connect(mapStateToProps, mapDispatchToProps),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
settings,
updatePending,
errors,
},
updatePending,
settings,
errors,
...rest,
}))
)(StreamSettings);
@@ -5,6 +5,7 @@ import TechSettings from '../components/TechSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import { updatePending } from '../../../actions/configure';
import { mapProps } from 'recompose';
const slots = ['adminTechSettings'];
@@ -38,5 +39,17 @@ export default compose(
}
`,
}),
connect(mapStateToProps, mapDispatchToProps)
connect(mapStateToProps, mapDispatchToProps),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
settings,
updatePending,
errors,
},
updatePending,
settings,
errors,
...rest,
}))
)(TechSettings);
@@ -10,12 +10,14 @@ const InitialStep = () => {
return (
<div className={cn(styles.step, styles.finalStep, 'talk-install-step-5')}>
<p>{t('install.final.description')}</p>
<Button raised>
<Link to="/admin">{t('install.final.launch')}</Link>
</Button>
<Button cStyle="black" raised>
<a href="http://coralproject.net">{t('install.final.close')}</a>
</Button>
<Link to="/admin">
<Button raised>{t('install.final.launch')}</Button>
</Link>
<a href="http://coralproject.net">
<Button cStyle="black" raised>
{t('install.final.close')}
</Button>
</a>
</div>
);
};
@@ -8,7 +8,7 @@ import styles from './Comment.css';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
import AdminCommentContent from 'coral-framework/components/AdminCommentContent';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import ApproveButton from 'coral-admin/src/components/ApproveButton';
@@ -53,7 +53,6 @@ class Comment extends React.Component {
comment,
selected,
className,
data,
root,
root: { settings },
currentAsset,
@@ -62,7 +61,6 @@ class Comment extends React.Component {
} = this.props;
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
const queryData = { root, comment, asset: comment.asset };
const formatterSettings = {
suspectWords: settings.wordlist.suspect,
@@ -70,6 +68,13 @@ class Comment extends React.Component {
body: comment.body,
};
const slotPassthrough = {
clearHeightCache,
root,
comment,
asset: comment.asset,
};
return (
<li
tabIndex={0}
@@ -113,16 +118,15 @@ class Comment extends React.Component {
<CommentLabels comment={comment} />
<Slot
fill="adminCommentInfoBar"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
passthrough={slotPassthrough}
/>
</div>
</div>
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{t('common.story')}:{' '}
{comment.asset.title ? comment.asset.title : comment.asset.url}
{!currentAsset && (
<Link to={`/admin/moderate/${comment.asset.id}`}>
{t('modqueue.moderate')}
@@ -134,13 +138,10 @@ class Comment extends React.Component {
<div className={styles.body}>
<Slot
fill="adminCommentContent"
data={data}
className={cn(styles.commentContent, 'talk-admin-comment')}
clearHeightCache={clearHeightCache}
queryData={queryData}
slotSize={1}
defaultComponent={CommentFormatter}
{...formatterSettings}
size={1}
defaultComponent={AdminCommentContent}
passthrough={{ ...slotPassthrough, ...formatterSettings }}
/>
<div className={styles.commentContentFooter}>
<a
@@ -156,6 +157,7 @@ class Comment extends React.Component {
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
<span className={styles.hasLinks}>
{/* TODO: translate string */}
<Icon name="error_outline" /> Contains Link
</span>
</IfHasLink>
@@ -169,18 +171,12 @@ class Comment extends React.Component {
onClick={this.reject}
/>
</div>
<Slot
fill="adminSideActions"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
<Slot fill="adminSideActions" passthrough={slotPassthrough} />
</div>
</div>
</CommentAnimatedEdit>
</div>
<CommentDetails
data={data}
root={root}
comment={comment}
clearHeightCache={clearHeightCache}
@@ -218,7 +214,6 @@ Comment.propTypes = {
id: PropTypes.string,
}),
}),
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
selected: PropTypes.bool,
};
@@ -126,7 +126,6 @@ class Moderation extends Component {
render() {
const {
root,
data,
moderation,
viewUserDetail,
activeTab,
@@ -148,6 +147,13 @@ class Moderation extends Component {
count: root[`${queue}Count`],
}));
const slotPassthrough = {
root,
asset,
activeTab,
handleCommentChange,
};
return (
<div>
<ModerationHeader
@@ -171,7 +177,6 @@ class Moderation extends Component {
/>
<ModerationQueue
key={`${activeTab}_${this.props.moderation.sortOrder}`}
data={this.props.data}
root={this.props.root}
currentAsset={asset}
comments={comments.nodes}
@@ -204,13 +209,7 @@ class Moderation extends Component {
closeSearch={this.closeSearch}
storySearchChange={this.props.storySearchChange}
/>
<Slot
data={data}
queryData={{ root, asset }}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill="adminModeration"
/>
<Slot fill="adminModeration" passthrough={slotPassthrough} />
</div>
);
}
@@ -6,10 +6,6 @@
margin-top: 16px;
}
:global(html) {
height: inherit;
}
.list {
outline: none;
}
@@ -344,7 +344,6 @@ class ModerationQueue extends React.Component {
child = (
<div style={style}>
<Comment
data={this.props.data}
root={this.props.root}
comment={comment}
dangling={
@@ -408,7 +407,6 @@ class ModerationQueue extends React.Component {
return (
<div className={styles.root}>
<Comment
data={this.props.data}
root={this.props.root}
key={comment.id}
comment={comment}
@@ -476,7 +474,6 @@ ModerationQueue.propTypes = {
hasNextPage: PropTypes.bool,
comments: PropTypes.array,
activeTab: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
currentUserId: PropTypes.string.isRequired,
};
@@ -111,14 +111,14 @@ class IndicatorContainer extends Component {
return null;
}
const slotPassthrough = {
handleCommentChange: this.handleCommentChange,
};
return (
<span>
<Indicator />
<Slot
data={this.props.data}
handleCommentChange={this.handleCommentChange}
fill="adminModerationIndicator"
/>
<Slot fill="adminModerationIndicator" passthrough={slotPassthrough} />
</span>
);
}
@@ -89,3 +89,12 @@
display: inline-block;
}
}
.statusDropdown {
width: 150px;
}
.statusDropdownOption {
min-width: 100px;
}
@@ -22,11 +22,20 @@ class Stories extends Component {
const closed = !!(closedAt && new Date(closedAt).getTime() < Date.now());
return (
<Dropdown
className={styles.statusDropdown}
value={closed}
onChange={value => this.props.onStatusChange(value, id)}
>
<Option value={false} label={t('streams.open')} />
<Option value={true} label={t('streams.closed')} />
<Option
value={false}
label={t('streams.open')}
className={styles.statusDropdownOption}
/>
<Option
value={true}
label={t('streams.closed')}
className={styles.statusDropdownOption}
/>
</Dropdown>
);
};
-9
View File
@@ -1,12 +1,3 @@
import LinkifyIt from 'linkify-it';
import tlds from 'tlds';
const linkify = new LinkifyIt();
linkify.tlds(tlds);
export function matchLinks(text) {
return linkify.match(text);
}
export const isPremod = mod => mod === 'PRE';
export const getModPath = (type = 'all', assetId) =>
@@ -71,16 +71,19 @@ export const setActiveTab = tab => dispatch => {
dispatch({ type: actions.SET_ACTIVE_TAB, tab });
};
// @Deprecated
export const addCommentBoxTag = tag => ({
type: actions.ADD_COMMENT_BOX_TAG,
tag,
});
// @Deprecated
export const removeCommentBoxTag = idx => ({
type: actions.REMOVE_COMMENT_BOX_TAG,
idx,
});
// @Deprecated
export const clearCommentBoxTags = () => ({
type: actions.CLEAR_COMMENT_BOX_TAGS,
});
@@ -63,6 +63,7 @@ export default class Embed extends React.Component {
} = this.props;
const hasHighlightedComment = !!commentId;
const popupUrl = `login?parentUrl=${encodeURIComponent(parentUrl)}`;
const slotPassthrough = { root };
return (
<div
@@ -84,7 +85,7 @@ export default class Embed extends React.Component {
/>
</IfSlotIsNotEmpty>
<Slot data={data} queryData={{ root }} fill="embed" />
<Slot passthrough={slotPassthrough} fill="embed" />
<ExtendableTabPanel
className="talk-embed-stream-tab-bar"
@@ -94,8 +95,7 @@ export default class Embed extends React.Component {
tabSlot="embedStreamTabs"
tabSlotPrepend="embedStreamTabsPrepend"
tabPaneSlot="embedStreamTabPanes"
slotProps={{ data }}
queryData={{ root }}
slotPassthrough={slotPassthrough}
tabs={this.getTabs()}
tabPanes={[
<TabPane
@@ -138,9 +138,5 @@ Embed.propTypes = {
commentId: PropTypes.string,
root: PropTypes.object,
activeTab: PropTypes.string,
data: PropTypes.shape({
loading: PropTypes.bool,
error: PropTypes.object,
refetch: PropTypes.func,
}).isRequired,
data: PropTypes.object.isRequired,
};
@@ -1,17 +1,12 @@
import React from 'react';
import ExtendableTabPanel from '../components/ExtendableTabPanel';
import { connect } from 'react-redux';
import { TabPane } from 'coral-ui';
import ExtendableTab from '../components/ExtendableTab';
import { getShallowChanges } from 'coral-framework/utils';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
import { withSlotElements } from 'coral-framework/hocs';
import { compose } from 'recompose';
class ExtendableTabPanelContainer extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
componentDidMount() {
this.handleFallback();
}
@@ -20,24 +15,6 @@ class ExtendableTabPanelContainer extends React.Component {
this.handleFallback(next);
}
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevKeys = this.getSlotElements(this.props.tabSlot, this.props).map(
el => el.key
);
const nextKeys = this.getSlotElements(next.tabSlot, next).map(
el => el.key
);
return !isEqual(prevKeys, nextKeys);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
handleFallback(props = this.props) {
if (this.getTabNames(props).indexOf(props.activeTab) === -1) {
props.setActiveTab(props.fallbackTab);
@@ -48,38 +25,23 @@ class ExtendableTabPanelContainer extends React.Component {
return this.getTabElements(props).map(el => el.props.tabId);
}
getSlotElements(slot, props = this.props) {
const { plugins } = this.context;
return plugins.getSlotElements(
slot,
props.reduxState,
props.slotProps,
props.queryData
);
}
getPluginTabElements(props = this.props) {
return this.getSlotTabElements(props.tabSlot);
return props.slotElements[0].map(this.createPluginTabFactory(props));
}
getPluginTabElementsPrepend(props = this.props) {
return this.getSlotTabElements(props.tabSlotPrepend);
return props.slotElements[1].map(this.createPluginTabFactory(props));
}
getSlotTabElements(slot) {
return this.getSlotElements(slot).map(el => {
return (
<ExtendableTab
tabId={el.type.talkPluginName}
key={el.type.talkPluginName}
>
{React.cloneElement(el, {
active: this.props.activeTab === el.type.talkPluginName,
})}
</ExtendableTab>
);
});
}
createPluginTabFactory = (props = this.props) => el => {
return (
<ExtendableTab tabId={el.key} key={el.key}>
{React.cloneElement(el, {
active: props.activeTab === el.key,
})}
</ExtendableTab>
);
};
getTabElements(props = this.props) {
const elements = [...this.getPluginTabElementsPrepend(props)];
@@ -92,14 +54,16 @@ class ExtendableTabPanelContainer extends React.Component {
return elements;
}
createPluginTabPane(el) {
return (
<TabPane tabId={el.key} key={el.key}>
{el}
</TabPane>
);
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotElements(props.tabPaneSlot).map(el => {
return (
<TabPane tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
{el}
</TabPane>
);
});
return props.slotElements[2].map(this.createPluginTabPane);
}
render() {
@@ -132,15 +96,15 @@ ExtendableTabPanelContainer.propTypes = {
tabSlot: PropTypes.string.isRequired,
tabSlotPrepend: PropTypes.string.isRequired,
tabPaneSlot: PropTypes.string.isRequired,
slotProps: PropTypes.object.isRequired,
queryData: PropTypes.object,
slotPassthrough: PropTypes.object,
className: PropTypes.string,
sub: PropTypes.bool,
loading: PropTypes.bool,
};
const mapStateToProps = state => ({
reduxState: state,
});
export default connect(mapStateToProps, null)(ExtendableTabPanelContainer);
export default compose(
withSlotElements({
slot: props => [props.tabSlot, props.tabSlotPrepend, props.tabPaneSlot],
passthroughPropName: 'slotPassthrough',
})
)(ExtendableTabPanelContainer);
@@ -8,7 +8,7 @@ const initialState = {
errors: {},
};
export default function config(state = initialState, action) {
export default function configure(state = initialState, action) {
switch (action.type) {
case actions.UPDATE_PENDING: {
let next = state;
@@ -7,11 +7,7 @@ class Configure extends React.Component {
render() {
return (
<div className="talk-embed-stream-configuration-container">
<Settings
data={this.props.data}
root={this.props.root}
asset={this.props.asset}
/>
<Settings root={this.props.root} asset={this.props.asset} />
<hr />
<AssetStatusInfo asset={this.props.asset} />
</div>
@@ -20,7 +16,6 @@ class Configure extends React.Component {
}
Configure.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
asset: PropTypes.object.isRequired,
};
@@ -1,38 +1,15 @@
import React from 'react';
import QuestionBox from '../../../components/QuestionBox';
import { Icon, Spinner } from 'coral-ui';
import DefaultQuestionBoxIcon from '../../../components/DefaultQuestionBoxIcon';
import cn from 'classnames';
import styles from './QuestionBoxBuilder.css';
import { Icon } from 'coral-ui';
import MarkdownEditor from 'coral-framework/components/MarkdownEditor';
const DefaultIcon = <DefaultQuestionBoxIcon className={styles.defaultIcon} />;
const icons = [{ default: DefaultIcon }, 'forum', 'build', 'format_quote'];
class QuestionBoxBuilder extends React.Component {
constructor() {
super();
this.state = {
loading: true,
};
}
componentWillMount() {
this.loadEditor();
}
async loadEditor() {
const {
default: MarkdownEditor,
} = await import('coral-framework/components/MarkdownEditor');
return this.setState({
loading: false,
MarkdownEditor,
});
}
render() {
const {
questionBoxIcon,
@@ -40,11 +17,6 @@ class QuestionBoxBuilder extends React.Component {
onContentChange,
onIconChange,
} = this.props;
const { loading, MarkdownEditor } = this.state;
if (loading) {
return <Spinner />;
}
return (
<div className={styles.root}>
@@ -25,9 +25,9 @@ class Settings extends React.Component {
onQuestionBoxContentChange,
canSave,
onApply,
slotProps,
queryData,
slotPassthrough,
} = this.props;
return (
<div className={styles.wrapper}>
<div className={styles.container}>
@@ -77,7 +77,7 @@ class Settings extends React.Component {
</div>
)}
</Configuration>
<Slot fill="streamSettings" queryData={queryData} {...slotProps} />
<Slot fill="streamSettings" passthrough={slotPassthrough} />
</div>
</div>
);
@@ -85,8 +85,7 @@ class Settings extends React.Component {
}
Settings.propTypes = {
queryData: PropTypes.object.isRequired,
slotProps: PropTypes.object.isRequired,
slotPassthrough: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
onToggleModeration: PropTypes.func.isRequired,
@@ -13,13 +13,7 @@ class ConfigureContainer extends React.Component {
return <div>{this.props.data.error.message}</div>;
}
return (
<Configure
data={this.props.data}
root={this.props.root}
asset={this.props.asset}
/>
);
return <Configure root={this.props.root} asset={this.props.asset} />;
}
}
@@ -57,17 +57,24 @@ class SettingsContainer extends React.Component {
const {
mergedSettings,
canSave,
data,
root,
asset,
errors,
updatePending,
} = this.props;
const slotPassthrough = {
root,
asset,
settings: mergedSettings,
updatePending,
errors,
};
return (
<Settings
settings={mergedSettings}
queryData={{ root, asset, settings: mergedSettings }}
slotProps={{ data, updatePending, errors }}
slotPassthrough={slotPassthrough}
savePending={this.savePending}
onToggleModeration={this.toggleModeration}
onTogglePremodLinks={this.togglePremodLinks}
@@ -82,7 +89,6 @@ class SettingsContainer extends React.Component {
}
SettingsContainer.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
asset: PropTypes.object.isRequired,
pending: PropTypes.object.isRequired,
@@ -0,0 +1,20 @@
.root {
text-align: center;
padding-top: 24px;
}
.icon {
font-size: 64px;
color: #3a4a52;
}
.title {
font-size: 18px;
margin-bottom: 8px;
}
.info {
margin-top: 0px;
font-size: 14px;
color: #7d8285;
}
@@ -0,0 +1,14 @@
import React from 'react';
import styles from './BlankCommentHistory.css';
import { Icon } from 'coral-ui';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
export default () => (
<section className={cn(styles.root, 'talk-my-profile-comment-history-blank')}>
<Icon name="chat" className={styles.icon} />
<h1 className={styles.title}>{t('comment_history_blank.title')}</h1>
<p className={styles.info}>{t('comment_history_blank.info')}</p>
</section>
);
@@ -15,6 +15,7 @@
.main {
min-width: 70%;
max-width: 100%;
}
.sidebar {
@@ -22,9 +22,9 @@ class Comment extends React.Component {
};
render() {
const { comment, data, root } = this.props;
const { comment, root } = this.props;
const reactionCount = getTotalReactionsCount(comment.action_summaries);
const queryData = { root, comment, asset: comment.asset };
const slotPassthrough = { root, comment, asset: comment.asset };
return (
<div className={styles.myComment}>
@@ -33,8 +33,8 @@ class Comment extends React.Component {
fill="commentContent"
defaultComponent={CommentContent}
className={cn(styles.commentBody, 'my-comment-body')}
data={data}
queryData={queryData}
passthrough={slotPassthrough}
size={1}
/>
<div className={cn(styles.commentSummary, 'comment-summary')}>
<span
@@ -98,9 +98,10 @@ class Comment extends React.Component {
fill="historyCommentTimestamp"
defaultComponent={CommentTimestamp}
className={'talk-history-comment-published-date'}
created_at={comment.created_at}
data={data}
queryData={queryData}
passthrough={{
created_at: comment.created_at,
...slotPassthrough,
}}
inline
/>
</li>
@@ -114,7 +115,6 @@ class Comment extends React.Component {
Comment.propTypes = {
comment: PropTypes.object.isRequired,
navigate: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
};
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Comment from '../containers/Comment';
import LoadMore from './LoadMore';
import BlankCommentHistory from './BlankCommentHistory';
class CommentHistory extends React.Component {
state = {
@@ -21,7 +22,10 @@ class CommentHistory extends React.Component {
};
render() {
const { navigate, comments, data, root } = this.props;
const { navigate, comments, root } = this.props;
if (!comments.nodes.length) {
return <BlankCommentHistory />;
}
return (
<div className="talk-my-profile-comment-history">
<div className="commentHistory__list">
@@ -29,7 +33,6 @@ class CommentHistory extends React.Component {
return (
<Comment
key={i}
data={data}
root={root}
comment={comment}
navigate={navigate}
@@ -52,7 +55,6 @@ CommentHistory.propTypes = {
comments: PropTypes.object.isRequired,
loadMore: PropTypes.func,
navigate: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object,
};
@@ -1,57 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import Slot from 'coral-framework/components/Slot';
import CommentHistory from '../containers/CommentHistory';
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
import { Tab, TabPane } from 'coral-ui';
import styles from './Profile.css';
import t from 'coral-framework/services/i18n';
import TabPanel from '../containers/TabPanel';
const Profile = ({
username,
emailAddress,
data,
root,
activeTab,
setActiveTab,
}) => (
<div className="talk-my-profile talk-profile-container">
<div className={styles.userInfo}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
return (
<div className="talk-my-profile talk-profile-container">
<div className={styles.userInfo}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
</div>
<Slot fill="profileSections" passthrough={slotPassthrough} />
<TabPanel root={root} slotPassthrough={slotPassthrough} />
</div>
<Slot fill="profileSections" data={data} queryData={{ root }} />
<ExtendableTabPanel
activeTab={activeTab}
setActiveTab={setActiveTab}
fallbackTab="comments"
tabSlot="profileTabs"
tabSlotPrepend="profileTabsPrepend"
tabPaneSlot="profileTabPanes"
slotProps={{ data }}
queryData={{ root }}
tabs={[
<Tab key="comments" tabId="comments">
{t('framework.my_comments')}
</Tab>,
]}
tabPanes={[
<TabPane key="comments" tabId="comments">
<CommentHistory data={data} root={root} />
</TabPane>,
]}
sub
/>
</div>
);
);
};
Profile.propTypes = {
username: PropTypes.string,
emailAddress: PropTypes.string,
data: PropTypes.object,
root: PropTypes.object,
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
slotPassthrough: PropTypes.object,
};
export default Profile;
@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Slot } from 'coral-framework/components';
class Settings extends React.Component {
render() {
const { root } = this.props;
const slotPassthrough = { root };
return (
<div>
<Slot fill="profileSettings" passthrough={slotPassthrough} />
</div>
);
}
}
Settings.propTypes = {
root: PropTypes.object,
};
export default Settings;
@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import CommentHistory from '../containers/CommentHistory';
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
import { Tab, TabPane } from 'coral-ui';
import t from 'coral-framework/services/i18n';
import Settings from '../containers/Settings';
const TabPanel = ({
root,
activeTab,
setActiveTab,
showSettingsTab,
slotPassthrough,
}) => {
const tabs = [
<Tab key="comments" tabId="comments">
{t('framework.my_comments')}
</Tab>,
];
if (showSettingsTab) {
tabs.push(
<Tab key="settings" tabId="settings">
{t('profile_settings')}
</Tab>
);
}
return (
<ExtendableTabPanel
activeTab={activeTab}
setActiveTab={setActiveTab}
fallbackTab="comments"
tabSlot="profileTabs"
tabSlotPrepend="profileTabsPrepend"
tabPaneSlot="profileTabPanes"
slotPassthrough={slotPassthrough}
tabs={tabs}
tabPanes={[
<TabPane key="comments" tabId="comments">
<CommentHistory root={root} />
</TabPane>,
<TabPane key="settings" tabId="settings">
<Settings root={root} />
</TabPane>,
]}
sub
/>
);
};
TabPanel.propTypes = {
root: PropTypes.object,
slotPassthrough: PropTypes.object,
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
showSettingsTab: PropTypes.bool.isRequired,
};
export default TabPanel;
@@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { compose, gql } from 'react-apollo';
import CommentHistory from '../components/CommentHistory';
import Comment from './Comment';
import { withFragments } from 'coral-framework/hocs';
import { withFragments, withFetchMore } from 'coral-framework/hocs';
import { appendNewNodes } from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
@@ -16,7 +16,7 @@ class CommentHistoryContainer extends Component {
};
loadMore = () => {
return this.props.data.fetchMore({
return this.props.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
@@ -43,7 +43,6 @@ class CommentHistoryContainer extends Component {
return (
<CommentHistory
comments={this.props.root.me.comments}
data={this.props.data}
root={this.props.root}
loadMore={this.loadMore}
navigate={this.navigate}
@@ -57,8 +56,8 @@ CommentHistoryContainer.contextTypes = {
};
CommentHistoryContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
fetchMore: PropTypes.func.isRequired,
};
const LOAD_MORE_QUERY = gql`
@@ -99,5 +98,6 @@ const mapStateToProps = state => ({
export default compose(
connect(mapStateToProps, null),
withCommentHistoryFragments
withCommentHistoryFragments,
withFetchMore
)(CommentHistoryContainer);
@@ -7,11 +7,10 @@ import { withQuery } from 'coral-framework/hocs';
import NotLoggedIn from '../components/NotLoggedIn';
import { Spinner } from 'coral-ui';
import Profile from '../components/Profile';
import CommentHistory from './CommentHistory';
import TabPanel from './TabPanel';
import { getDefinitionName } from 'coral-framework/utils';
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
import { setActiveTab } from '../../../actions/profile';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
class ProfileContainer extends Component {
@@ -23,7 +22,7 @@ class ProfileContainer extends Component {
}
render() {
const { currentUser, showSignInDialog, root, data } = this.props;
const { currentUser, showSignInDialog, root } = this.props;
const { me } = this.props.root;
const loading = this.props.data.loading;
@@ -41,15 +40,14 @@ class ProfileContainer extends Component {
const localProfile = currentUser.profiles.find(p => p.provider === 'local');
const emailAddress = localProfile && localProfile.id;
const slotPassthrough = { root };
return (
<Profile
username={me.username}
emailAddress={emailAddress}
data={data}
root={root}
activeTab={this.props.activeTab}
setActiveTab={this.props.setActiveTab}
slotPassthrough={slotPassthrough}
/>
);
}
@@ -60,16 +58,9 @@ ProfileContainer.propTypes = {
root: PropTypes.object,
currentUser: PropTypes.object,
showSignInDialog: PropTypes.func,
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
};
const slots = [
'profileSections',
'profileTabs',
'profileTabsPrepend',
'profileTabPanes',
];
const slots = ['profileSections'];
const withProfileQuery = withQuery(
gql`
@@ -78,10 +69,10 @@ const withProfileQuery = withQuery(
id
username
}
...${getDefinitionName(CommentHistory.fragments.root)}
...${getDefinitionName(TabPanel.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
}
${CommentHistory.fragments.root}
${TabPanel.fragments.root}
`,
{
options: {
@@ -92,11 +83,10 @@ const withProfileQuery = withQuery(
const mapStateToProps = state => ({
currentUser: state.auth.user,
activeTab: state.profile.activeTab,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({ showSignInDialog, setActiveTab }, dispatch);
bindActionCreators({ showSignInDialog }, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
@@ -0,0 +1,26 @@
import React from 'react';
import { compose, gql } from 'react-apollo';
import Settings from '../components/Settings';
import { withFragments } from 'coral-framework/hocs';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
const slots = ['profileSettings'];
class SettingsContainer extends React.Component {
render() {
return <Settings {...this.props} />;
}
}
const enhance = compose(
withFragments({
root: gql`
fragment TalkEmbedStream_ProfileSettings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
})
);
export default enhance(SettingsContainer);
@@ -0,0 +1,64 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { withSlotElements, withFragments } from 'coral-framework/hocs';
import Settings from './Settings';
import CommentHistory from './CommentHistory';
import { getDefinitionName } from 'coral-framework/utils';
import TabPanel from '../components/TabPanel';
import { setActiveTab } from '../../../actions/profile';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
class TabPanelContainer extends Component {
render() {
return (
<TabPanel
root={this.props.root}
slotPassthrough={this.props.slotPassthrough}
activeTab={this.props.activeTab}
setActiveTab={this.props.setActiveTab}
showSettingsTab={this.props.profileSettingsSlotElements.length > 0}
/>
);
}
}
TabPanelContainer.propTypes = {
root: PropTypes.object,
slotPassthrough: PropTypes.object,
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
profileSettingsSlotElements: PropTypes.array.isRequired,
};
const slots = ['profileTabs', 'profileTabsPrepend', 'profileTabPanes'];
const mapStateToProps = state => ({
activeTab: state.profile.activeTab,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({ setActiveTab }, dispatch);
export default compose(
withFragments({
root: gql`
fragment TalkEmbedStream_ProfileTabPanel_root on RootQuery {
__typename
...${getDefinitionName(CommentHistory.fragments.root)}
...${getDefinitionName(Settings.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
}
${CommentHistory.fragments.root}
${Settings.fragments.root}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
withSlotElements({
slot: 'profileSettings',
propName: 'profileSettingsSlotElements',
passthroughPropName: 'slotPassthrough',
})
)(TabPanelContainer);
@@ -126,7 +126,6 @@ class AllCommentsPane extends React.Component {
render() {
const {
data,
root,
comments,
commentClassNames,
@@ -164,7 +163,6 @@ class AllCommentsPane extends React.Component {
return (
<Comment
commentClassNames={commentClassNames}
data={data}
root={root}
disableReply={disableReply}
setActiveReplyBox={setActiveReplyBox}
@@ -205,7 +203,6 @@ class AllCommentsPane extends React.Component {
}
AllCommentsPane.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
comments: PropTypes.object,
commentClassNames: PropTypes.array,
@@ -16,7 +16,7 @@ import mapValues from 'lodash/mapValues';
import get from 'lodash/get';
import LoadMore from './LoadMore';
import { getEditableUntilDate } from './util';
import { getEditableUntilDate } from '../util';
import { findCommentWithId } from '../../../graphql/utils';
import CommentContent from 'coral-framework/components/CommentContent';
import Slot from 'coral-framework/components/Slot';
@@ -181,7 +181,6 @@ export default class Comment extends React.Component {
}),
charCountEnable: PropTypes.bool.isRequired,
maxCharCount: PropTypes.number,
data: PropTypes.object,
root: PropTypes.object,
loadMore: PropTypes.func,
postDontAgree: PropTypes.func,
@@ -417,7 +416,6 @@ export default class Comment extends React.Component {
{view.map(reply => {
return (
<CommentContainer
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
@@ -487,7 +485,6 @@ export default class Comment extends React.Component {
renderComment() {
const {
asset,
data,
root,
depth,
comment,
@@ -535,12 +532,8 @@ export default class Comment extends React.Component {
);
// props that are passed down the slots.
const slotProps = {
data,
const slotPassthrough = {
depth,
};
const queryData = {
root,
asset,
comment,
@@ -551,8 +544,7 @@ export default class Comment extends React.Component {
<Slot
className={cn(styles.commentAvatar, 'talk-stream-comment-avatar')}
fill="commentAvatar"
{...slotProps}
queryData={queryData}
passthrough={slotPassthrough}
inline
/>
@@ -573,8 +565,7 @@ export default class Comment extends React.Component {
className={cn(styles.username, 'talk-stream-comment-user-name')}
fill="commentAuthorName"
defaultComponent={CommentAuthorName}
queryData={queryData}
{...slotProps}
passthrough={slotPassthrough}
/>
<div
@@ -591,8 +582,7 @@ export default class Comment extends React.Component {
'talk-stream-comment-author-tags'
)}
fill="commentAuthorTags"
queryData={queryData}
{...slotProps}
passthrough={slotPassthrough}
inline
/>
</div>
@@ -607,9 +597,10 @@ export default class Comment extends React.Component {
fill="commentTimestamp"
defaultComponent={CommentTimestamp}
className={'talk-stream-comment-published-date'}
created_at={comment.created_at}
queryData={queryData}
{...slotProps}
passthrough={{
created_at: comment.created_at,
...slotPassthrough,
}}
/>
{comment.editing && comment.editing.edited ? (
<span>
@@ -624,8 +615,7 @@ export default class Comment extends React.Component {
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
queryData={queryData}
passthrough={slotPassthrough}
/>
{isActive &&
@@ -665,9 +655,8 @@ export default class Comment extends React.Component {
fill="commentContent"
className="talk-stream-comment-content"
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
slotSize={1}
size={1}
passthrough={slotPassthrough}
/>
</div>
)}
@@ -678,8 +667,7 @@ export default class Comment extends React.Component {
<div className="talk-embed-stream-comment-actions-container-left commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
{...slotProps}
queryData={queryData}
passthrough={slotPassthrough}
inline
/>
@@ -696,9 +684,7 @@ export default class Comment extends React.Component {
<div className="talk-embed-stream-comment-actions-container-right commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
queryData={queryData}
passthrough={slotPassthrough}
inline
/>
<ActionButton>
@@ -18,14 +18,8 @@ class CommentForm extends React.Component {
charCountEnable: PropTypes.bool.isRequired,
maxCharCount: PropTypes.number,
// DOM ID for form input that edits comment body
bodyInputId: PropTypes.string,
// screen reader label for input that edits comment body
bodyLabel: PropTypes.string,
// Placeholder for input that edits comment body
bodyPlaceholder: PropTypes.string,
// Unique identifier for this form
id: PropTypes.string,
// render at start of button container (useful for extra buttons)
buttonContainerStart: PropTypes.node,
@@ -37,15 +31,15 @@ class CommentForm extends React.Component {
submitButtonCStyle: PropTypes.string,
// return whether the submit button should be enabled for the provided
// comment ({ body }) (for reasons other than charCount)
// input (for reasons other than charCount)
submitEnabled: PropTypes.func,
// className to add to buttons
submitButtonClassName: PropTypes.string,
cancelButtonClassName: PropTypes.string,
body: PropTypes.string.isRequired,
onBodyChange: PropTypes.func.isRequired,
input: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func,
state: PropTypes.string,
@@ -53,13 +47,12 @@ class CommentForm extends React.Component {
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
isEdit: PropTypes.bool,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
static get defaultProps() {
return {
bodyLabel: t('comment_box.comment'),
bodyPlaceholder: t('comment_box.comment'),
submitText: t('comment_box.post'),
submitButtonCStyle: 'darkGrey',
submitEnabled: () => true,
@@ -90,20 +83,20 @@ class CommentForm extends React.Component {
cancelButtonClassName,
submitButtonClassName,
charCountEnable,
body,
input,
loadingState,
comment,
root,
} = this.props;
const length = body.length;
const length = input.body.length;
const isRespectingMaxCount = length =>
charCountEnable && maxCharCount && length > maxCharCount;
const disableSubmitButton =
!length ||
body.trim().length === 0 ||
input.body.trim().length === 0 ||
isRespectingMaxCount(length) ||
!submitEnabled({ body }) ||
!submitEnabled(input) ||
loadingState === 'loading';
const disableCancelButton = loadingState === 'loading';
const disableTextArea = loadingState === 'loading';
@@ -113,17 +106,16 @@ class CommentForm extends React.Component {
<DraftArea
root={root}
comment={comment}
id={this.props.bodyInputId}
label={this.props.bodyLabel}
value={body}
placeholder={this.props.bodyPlaceholder}
onChange={this.props.onBodyChange}
id={this.props.id}
input={input}
onInputChange={this.props.onInputChange}
disabled={disableTextArea}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
isReply={this.props.isReply}
isEdit={this.props.isEdit}
/>
<div className={cn(styles.buttonContainer, `${name}-button-container`)}>
{this.props.buttonContainerStart}
@@ -13,17 +13,17 @@ import styles from './DraftArea.css';
*/
export default class DraftArea extends React.Component {
renderCharCount() {
const { value, maxCharCount } = this.props;
const { input, maxCharCount } = this.props;
const className = cn(
styles.charCount,
'talk-plugin-commentbox-char-count',
{
[`${styles.charMax} talk-plugin-commentbox-char-max`]:
value.length > maxCharCount,
input.body.length > maxCharCount,
}
);
const remaining = maxCharCount - value.length;
const remaining = maxCharCount - input.body.length;
return (
<div className={className}>
@@ -32,48 +32,61 @@ export default class DraftArea extends React.Component {
);
}
getLabel() {
if (this.props.isEdit) {
return t('edit_comment.body_input_label');
}
return this.props.isReply ? t('comment_box.reply') : t('comment.comment');
}
getPlaceholder() {
if (this.props.isEdit) {
return '';
}
return this.getLabel();
}
render() {
const {
value,
placeholder,
input,
id,
disabled,
rows,
label,
charCountEnable,
maxCharCount,
onChange,
queryData,
onInputChange,
isReply,
isEdit,
registerHook,
unregisterHook,
root,
comment,
} = this.props;
const tASettings = {
value,
placeholder,
id,
onChange,
rows,
disabled,
isReply,
};
return (
<div>
<div id={id}>
<div
className={cn(styles.container, 'talk-plugin-commentbox-container')}
>
<label htmlFor={id} className="screen-reader-text" aria-hidden={true}>
{label}
</label>
<Slot
fill="draftArea"
defaultComponent={DraftAreaContent}
className={styles.content}
queryData={queryData}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
{...tASettings}
passthrough={{
id,
root,
comment,
registerHook,
unregisterHook,
input,
onInputChange,
disabled,
isReply,
isEdit,
placeholder: this.getPlaceholder(),
label: this.getLabel(),
}}
/>
{/* Is this slot here legitimate? (kiwi) */}
<Slot fill="commentInputArea" />
</div>
{charCountEnable && maxCharCount > 0 && this.renderCharCount()}
@@ -82,22 +95,17 @@ export default class DraftArea extends React.Component {
}
}
DraftArea.defaultProps = {
rows: 3,
};
DraftArea.propTypes = {
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
id: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
label: PropTypes.string,
onChange: PropTypes.func,
input: PropTypes.object,
onInputChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
queryData: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
isEdit: PropTypes.bool,
};
@@ -3,36 +3,49 @@ import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './DraftAreaContent.css';
const DraftAreaContent = ({
value,
placeholder,
id,
onChange,
rows,
disabled,
}) => (
<textarea
className={cn(styles.content, 'talk-plugin-commentbox-textarea')}
value={value}
placeholder={placeholder}
id={id}
onChange={e => onChange(e.target.value)}
rows={rows}
disabled={disabled}
/>
);
DraftAreaContent.defaultProps = {
rows: 3,
};
class DraftAreaContent extends React.Component {
render() {
const {
input,
id,
onInputChange,
disabled,
label,
placeholder,
} = this.props;
const inputId = `${id}-textarea`;
return (
<div>
<label
htmlFor={inputId}
className="screen-reader-text"
aria-hidden={true}
>
{label}
</label>
<textarea
id={inputId}
className={cn(styles.content, 'talk-plugin-commentbox-textarea')}
value={input.body}
placeholder={placeholder}
onChange={e => onInputChange({ body: e.target.value })}
rows={3}
disabled={disabled}
/>
</div>
);
}
}
DraftAreaContent.propTypes = {
id: PropTypes.string,
value: PropTypes.string,
input: PropTypes.object,
placeholder: PropTypes.string,
onChange: PropTypes.func,
label: PropTypes.string,
onInputChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
isEdit: PropTypes.bool,
isReply: PropTypes.bool,
};
export default DraftAreaContent;
@@ -1,12 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { notifyForNewCommentStatus } from '../helpers';
import CommentForm from '../containers/CommentForm';
import styles from './Comment.css';
import { CountdownSeconds } from './CountdownSeconds';
import { getEditableUntilDate } from './util';
import { can } from 'coral-framework/services/perms';
import { Icon } from 'coral-ui';
import t from 'coral-framework/services/i18n';
@@ -15,187 +12,83 @@ import t from 'coral-framework/services/i18n';
* Renders a Comment's body in such a way that the end-user can edit it and save changes
*/
class EditableCommentContent extends React.Component {
static propTypes = {
// show notification to the user (e.g. for errors)
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
// comment that is being edited
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string,
editing: PropTypes.shape({
edited: PropTypes.bool,
// ISO8601
editableUntil: PropTypes.string,
}),
}).isRequired,
// logged in user
currentUser: PropTypes.shape({
id: PropTypes.string.isRequired,
}),
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
// edit a comment, passed {{ body }}
editComment: PropTypes.func,
// called when editing should be stopped
stopEditing: PropTypes.func,
};
unmounted = false;
constructor(props) {
super(props);
this.editWindowExpiryTimeout = null;
this.state = {
body: props.comment.body,
loadingState: '',
// data: {@object} contains data that might be useful for plugins, metadata, etc
data: {},
};
}
componentDidMount() {
const editableUntil = getEditableUntilDate(this.props.comment);
const now = new Date();
const editWindowRemainingMs = editableUntil && editableUntil - now;
if (editWindowRemainingMs > 0) {
this.editWindowExpiryTimeout = setTimeout(() => {
this.forceUpdate();
}, editWindowRemainingMs);
}
}
componentWillUnmount() {
this.unmounted = true;
if (this.editWindowExpiryTimeout) {
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
}
}
handleBodyChange = (body, data) => {
this.setState(state => ({
body,
data: {
...state.data,
...data,
},
}));
};
handleSubmit = async () => {
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({ loadingState: 'loading' });
const { editComment, stopEditing } = this.props;
if (typeof editComment !== 'function') {
return;
}
let input = {
body: this.state.body,
...this.state.data,
};
let response;
try {
response = await editComment(input);
if (!this.unmounted) {
this.setState({ loadingState: 'success' });
}
const status = response.data.editComment.comment.status;
notifyForNewCommentStatus(this.props.notify, status);
if (typeof stopEditing === 'function') {
stopEditing();
}
} catch (error) {
this.setState({ loadingState: 'error' });
}
};
getEditableUntil = (props = this.props) => {
return getEditableUntilDate(props.comment);
};
isEditWindowExpired = (props = this.props) => {
return this.getEditableUntil(props) - new Date() < 0;
};
isSubmitEnabled = comment => {
// should be disabled if user hasn't actually changed their
// original comment
renderButtonContainerStart() {
return (
comment.body !== this.props.comment.body && !this.isEditWindowExpired()
<div className={styles.buttonContainerLeft}>
<span className={styles.editWindowRemaining}>
{this.props.editWindowExpired ? (
<span>
{t('edit_comment.edit_window_expired')}
<span>
&nbsp;<a className={styles.link} onClick={this.props.onCancel}>
{t('edit_comment.edit_window_expired_close')}
</a>
</span>
</span>
) : (
<span>
<Icon name="timer" className={styles.timerIcon} />{' '}
{t('edit_comment.edit_window_timer_prefix')}
<CountdownSeconds
until={this.props.editableUntil}
classNameForMsRemaining={remainingMs =>
remainingMs <= 10 * 1000 ? styles.editWindowAlmostOver : ''
}
/>
</span>
)}
</span>
</div>
);
};
}
render() {
const id = `edit-draft_${this.props.comment.id}`;
return (
<div className={styles.editCommentForm}>
<CommentForm
isEdit
root={this.props.root}
comment={this.props.comment}
defaultValue={this.props.comment.body}
bodyInputId={id}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
submitEnabled={this.isSubmitEnabled}
body={this.state.body}
onBodyChange={this.handleBodyChange}
onSubmit={this.handleSubmit}
submitEnabled={this.props.submitEnabled}
input={this.props.input}
onInputChange={this.props.onInputChange}
onSubmit={this.props.onSubmit}
onCancel={this.props.onCancel}
loadingState={this.props.loadingState}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
buttonContainerStart={this.renderButtonContainerStart()}
submitButtonClassName={styles.button}
cancelButtonClassName={styles.button}
bodyLabel={t('edit_comment.body_input_label')}
bodyPlaceholder=""
submitText={<span>{t('edit_comment.save_button')}</span>}
submitButtonCStyle="green"
onCancel={this.props.stopEditing}
submitButtonClassName={styles.button}
cancelButtonClassName={styles.button}
loadingState={this.state.loadingState}
buttonContainerStart={
<div className={styles.buttonContainerLeft}>
<span className={styles.editWindowRemaining}>
{this.isEditWindowExpired() ? (
<span>
{t('edit_comment.edit_window_expired')}
{typeof this.props.stopEditing === 'function' ? (
<span>
&nbsp;<a
className={styles.link}
onClick={this.props.stopEditing}
>
{t('edit_comment.edit_window_expired_close')}
</a>
</span>
) : null}
</span>
) : (
<span>
<Icon name="timer" className={styles.timerIcon} />{' '}
{t('edit_comment.edit_window_timer_prefix')}
<CountdownSeconds
until={this.getEditableUntil()}
classNameForMsRemaining={remainingMs =>
remainingMs <= 10 * 1000
? styles.editWindowAlmostOver
: ''
}
/>
</span>
)}
</span>
</div>
}
id={id}
/>
</div>
);
}
}
EditableCommentContent.propTypes = {
charCountEnable: PropTypes.bool,
submitEnabled: PropTypes.func,
maxCharCount: PropTypes.number,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
input: PropTypes.object.isRequired,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
onInputChange: PropTypes.func,
onSubmit: PropTypes.func,
onCancel: PropTypes.func,
loadingState: PropTypes.string,
editWindowExpired: PropTypes.bool,
editableUntil: PropTypes.object,
};
export default EditableCommentContent;
@@ -15,14 +15,13 @@ import QuestionBox from '../../../components/QuestionBox';
import { Tab, TabCount, TabPane } from 'coral-ui';
import cn from 'classnames';
import get from 'lodash/get';
import { reverseCommentParentTree } from '../../../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
import ChangedUsername from './ChangedUsername';
import CommentNotFound from '../containers/CommentNotFound';
import styles from './Stream.css';
import ChangedUsername from './ChangedUsername';
class Stream extends React.Component {
constructor(props) {
super(props);
@@ -40,7 +39,6 @@ class Stream extends React.Component {
renderHighlightedComment() {
const {
data,
root,
activeReplyBox,
setActiveReplyBox,
@@ -91,7 +89,6 @@ class Stream extends React.Component {
</div>
<Comment
data={data}
root={root}
commentClassNames={commentClassNames}
setActiveReplyBox={setActiveReplyBox}
@@ -122,7 +119,6 @@ class Stream extends React.Component {
renderExtendableTabPanel() {
const {
data,
root,
activeReplyBox,
setActiveReplyBox,
@@ -147,15 +143,14 @@ class Stream extends React.Component {
loading,
} = this.props;
const slotProps = { data };
const slotQueryData = { root, asset };
const slotPassthrough = { root, asset };
// `key` of `ExtendableTabPanel` 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} />
<Slot fill="streamFilter" passthrough={slotPassthrough} />
</div>
<ExtendableTabPanel
@@ -166,8 +161,7 @@ class Stream extends React.Component {
tabSlot="streamTabs"
tabSlotPrepend="streamTabsPrepend"
tabPaneSlot="streamTabPanes"
slotProps={slotProps}
queryData={slotQueryData}
slotPassthrough={slotPassthrough}
loading={loading}
tabs={
<Tab tabId={'all'} key="all">
@@ -180,7 +174,6 @@ class Stream extends React.Component {
tabPanes={
<TabPane tabId="all" key="all">
<AllCommentsPane
data={data}
root={root}
asset={asset}
comments={comments}
@@ -212,7 +205,6 @@ class Stream extends React.Component {
render() {
const {
data,
root,
appendItemArray,
asset,
@@ -243,13 +235,17 @@ class Stream extends React.Component {
!changedUsername &&
!highlightedComment) ||
keepCommentBox);
const slotProps = { data };
const slotQueryData = { root, asset };
if (highlightedComment === null) {
return <StreamError>{t('stream.comment_not_found')}</StreamError>;
return (
<StreamError>
<CommentNotFound />
</StreamError>
);
}
const slotPassthrough = { root, asset };
return (
<div id="stream" className={styles.root}>
{open ? (
@@ -263,11 +259,7 @@ class Stream extends React.Component {
content={asset.settings.questionBoxContent}
icon={asset.settings.questionBoxIcon}
>
<Slot
fill="streamQuestionArea"
queryData={slotQueryData}
{...slotProps}
/>
<Slot fill="streamQuestionArea" passthrough={slotPassthrough} />
</QuestionBox>
)}
{!banned &&
@@ -304,7 +296,7 @@ class Stream extends React.Component {
<p>{asset.settings.closedMessage}</p>
)}
<Slot fill="stream" queryData={slotQueryData} {...slotProps} />
<Slot fill="stream" passthrough={slotPassthrough} />
{currentUser && (
<ModerationLink
@@ -324,7 +316,6 @@ class Stream extends React.Component {
Stream.propTypes = {
asset: PropTypes.object,
activeStreamTab: PropTypes.string,
data: PropTypes.object,
root: PropTypes.object,
activeReplyBox: PropTypes.string,
setActiveReplyBox: PropTypes.func,
@@ -16,7 +16,6 @@ import { nest } from '../../../graphql/utils';
const slots = [
'streamQuestionArea',
'commentInputArea',
'commentInputDetailArea',
'commentInfoBar',
'commentActions',
@@ -7,10 +7,24 @@ import Slot from 'coral-framework/components/Slot';
import { connect } from 'react-redux';
import CommentForm from '../containers/CommentForm';
import { notifyForNewCommentStatus } from '../helpers';
import withHooks from '../hocs/withHooks';
import { compose } from 'recompose';
import once from 'lodash/once';
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
export const name = 'talk-plugin-commentbox';
// @Deprecated
const showOldTagsWarningOnce = once(() => {
if (process.env.NODE_ENV !== 'production') {
console.warn(
'Using `addTags` and `removeTags` is deprecated. Please switch to `onInputChange` and `input` instead'
);
}
});
const initialInput = { body: '', tags: [] };
/**
* Container for posting a new Comment
*/
@@ -19,14 +33,8 @@ class CommentBox extends React.Component {
super(props);
this.state = {
body: '',
loadingState: '',
// data: {@object} contains data that might be useful for plugins
data: {},
hooks: {
preSubmit: [],
postSubmit: [],
},
input: initialInput,
};
}
@@ -56,29 +64,38 @@ class CommentBox extends React.Component {
return;
}
// @Deprecated
const deprecatedTags = this.props.tags || [];
if (deprecatedTags.length) {
showOldTagsWarningOnce();
}
const tags = this.state.input.tags || [];
let input = {
asset_id: assetId,
parent_id: parentId,
body: this.state.body,
tags: this.props.tags,
...this.state.data,
...this.state.input,
tags: [...deprecatedTags, ...tags],
};
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach(hook =>
hook(input, this.handleBodyChange)
);
this.props.forEachHook('preSubmit', hook => {
const result = hook(input);
if (result) {
input = result;
}
});
this.setState({ loadingState: 'loading' });
postComment(input, 'comments')
.then(({ data }) => {
this.setState({ loadingState: 'success', body: '' });
this.setState({ loadingState: 'success', input: initialInput });
const postedComment = data.createComment.comment;
const actions = data.createComment.actions;
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach(hook =>
hook(data, this.handleBodyChange)
this.props.forEachHook('postSubmit', hook =>
hook(data, this.handleInputChange)
);
notifyForNewCommentStatus(notify, postedComment.status, actions);
@@ -92,62 +109,32 @@ class CommentBox extends React.Component {
});
};
handleBodyChange = (body, data) => {
handleInputChange = input => {
this.setState(state => ({
body,
data: {
...state.data,
...data,
input: {
...state.input,
...input,
},
}));
};
registerHook = (hookType = '', hook = () => {}) => {
if (typeof hook !== 'function') {
return console.warn(
`Hooks must be functions. Please check your ${hookType} hooks`
);
} else if (typeof hookType === 'string') {
this.setState(state => ({
hooks: {
...state.hooks,
[hookType]: [...state.hooks[hookType], hook],
},
}));
return {
hookType,
hook,
};
} else {
return console.warn(
'hookTypes must be a string. Please check your hooks'
);
}
};
unregisterHook = hookData => {
const { hookType, hook } = hookData;
this.setState(state => {
let newHooks = state.hooks[newHooks];
const idx = state.hooks[hookType].indexOf(hook);
if (idx !== -1) {
newHooks = [
...state.hooks[hookType].slice(0, idx),
...state.hooks[hookType].slice(idx + 1),
];
}
return {
hooks: {
...state.hooks,
[hookType]: newHooks,
},
};
});
};
renderButtonContainerStart() {
const { root, isReply, registerHook, unregisterHook } = this.props;
return (
<Slot
fill="commentInputDetailArea"
passthrough={{
root,
registerHook: registerHook,
unregisterHook: unregisterHook,
isReply,
input: this.state.input,
onInputChange: this.handleInputChange,
}}
inline
/>
);
}
render() {
const {
@@ -157,6 +144,8 @@ class CommentBox extends React.Component {
parentId,
comment,
root,
registerHook,
unregisterHook,
} = this.props;
let { onCancel } = this.props;
@@ -178,25 +167,15 @@ class CommentBox extends React.Component {
root={root}
comment={comment}
defaultValue={this.props.defaultValue}
bodyLabel={isReply ? t('comment_box.reply') : t('comment.comment')}
maxCharCount={maxCharCount}
charCountEnable={this.props.charCountEnable}
bodyPlaceholder={t('comment.comment')}
bodyInputId={id}
body={this.state.body}
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
id={id}
input={this.state.input}
registerHook={registerHook}
unregisterHook={unregisterHook}
isReply={isReply}
buttonContainerStart={
<Slot
fill="commentInputDetailArea"
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
isReply={isReply}
inline
/>
}
onBodyChange={this.handleBodyChange}
buttonContainerStart={this.renderButtonContainerStart()}
onInputChange={this.handleInputChange}
loadingState={this.state.loadingState}
onCancel={onCancel}
onSubmit={this.handleSubmit}
@@ -223,6 +202,9 @@ CommentBox.propTypes = {
tags: PropTypes.array,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
forEachHook: PropTypes.func.isRequired,
};
CommentBox.fragments = CommentForm.fragments;
@@ -231,4 +213,9 @@ const mapStateToProps = state => ({
tags: state.stream.commentBoxTags,
});
export default connect(mapStateToProps, null)(CommentBox);
const enhance = compose(
withHooks(['preSubmit', 'postSubmit']),
connect(mapStateToProps, null)
);
export default enhance(CommentBox);
@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'coral-ui';
import { setActiveTab } from '../../../actions/embed';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import t from 'coral-framework/services/i18n';
class CommentNotFound extends React.Component {
showAllTab = () => {
this.props.setActiveTab('all');
};
render() {
return (
<div>
<p>{t('stream.comment_not_found')}</p>
<Button onClick={this.showAllTab}>Show all comments</Button>
</div>
);
}
}
CommentNotFound.propTypes = {
setActiveTab: PropTypes.func,
};
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
setActiveTab,
},
dispatch
);
export default connect(null, mapDispatchToProps)(CommentNotFound);
@@ -17,9 +17,17 @@ class DraftAreaContainer extends React.Component {
}
async initValue() {
const value = await this.context.pymSessionStorage.getItem(this.getPath());
if (value && this.props.onChange) {
this.props.onChange(value);
const input = await this.context.pymSessionStorage.getItem(this.getPath());
if (input && this.props.onInputChange) {
let parsed = '';
// Older version saved a normal string, catch those and ignore them.
try {
parsed = JSON.parse(input);
} catch (_e) {}
if (typeof parsed === 'object') {
this.props.onInputChange(parsed);
}
}
}
@@ -27,14 +35,13 @@ class DraftAreaContainer extends React.Component {
return `${STORAGE_PATH}_${this.props.id}`;
};
onChange = (body, data) => {
this.props.onChange && this.props.onChange(body, data);
};
componentWillReceiveProps(nextProps) {
if (this.props.value !== nextProps.value) {
if (nextProps.value) {
this.context.pymSessionStorage.setItem(this.getPath(), nextProps.value);
if (this.props.input !== nextProps.input) {
if (nextProps.input) {
this.context.pymSessionStorage.setItem(
this.getPath(),
JSON.stringify(nextProps.input)
);
} else {
this.context.pymSessionStorage.removeItem(this.getPath());
}
@@ -42,23 +49,20 @@ class DraftAreaContainer extends React.Component {
}
render() {
const queryData = { comment: this.props.comment, root: this.props.root };
return (
<DraftArea
queryData={queryData}
value={this.props.value}
placeholder={this.props.placeholder}
root={this.props.root}
comment={this.props.comment}
input={this.props.input}
id={this.props.id}
onChange={this.onChange}
rows={this.props.rows}
onInputChange={this.props.onInputChange}
disabled={this.props.disabled}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
label={this.props.label}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
isReply={this.props.isReply}
isEdit={this.props.isEdit}
/>
);
}
@@ -74,20 +78,18 @@ DraftAreaContainer.propTypes = {
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
placeholder: PropTypes.string,
onChange: PropTypes.func.isRequired,
input: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
rows: PropTypes.number,
label: PropTypes.string.isRequired,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
isEdit: PropTypes.bool,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
const slots = ['draftArea'];
const slots = ['draftArea', 'commentInputArea'];
export default withFragments({
root: gql`
@@ -1,11 +1,150 @@
import React from 'react';
import PropTypes from 'prop-types';
import { notifyForNewCommentStatus } from '../helpers';
import { getEditableUntilDate } from '../util';
import { can } from 'coral-framework/services/perms';
import t from 'coral-framework/services/i18n';
import EditableCommentContent from '../components/EditableCommentContent';
import CommentForm from './CommentForm';
import withHooks from '../hocs/withHooks';
import { compose } from 'recompose';
const EditableCommentContentContainer = props => (
<EditableCommentContent {...props} />
);
/**
* Renders a Comment's body in such a way that the end-user can edit it and save changes
*/
class EditableCommentContentContainer extends React.Component {
unmounted = false;
editWindowExpiryTimeout = null;
state = {
loadingState: '',
submitEnabled: false,
input: {
body: this.props.comment.body,
},
};
componentDidMount() {
const editableUntil = getEditableUntilDate(this.props.comment);
const now = new Date();
const editWindowRemainingMs = editableUntil && editableUntil - now;
if (editWindowRemainingMs > 0) {
this.editWindowExpiryTimeout = setTimeout(() => {
this.forceUpdate();
}, editWindowRemainingMs);
}
}
componentWillUnmount() {
this.unmounted = true;
if (this.editWindowExpiryTimeout) {
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
}
}
handleInputChange = input => {
this.setState(state => ({
submitEnabled: true,
input: {
...state.input,
...input,
},
}));
};
handleSubmit = async () => {
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({ loadingState: 'loading' });
const { editComment, stopEditing } = this.props;
if (typeof editComment !== 'function') {
return;
}
let input = this.state.input;
// Execute preSubmit Hooks
this.props.forEachHook('preSubmit', hook => {
const result = hook(input);
if (result) {
input = result;
}
});
let response;
try {
response = await editComment(input);
// Execute postSubmit Hooks
this.props.forEachHook('postSubmit', hook =>
hook(response, this.handleInputChange)
);
if (!this.unmounted) {
this.setState({ loadingState: 'success' });
}
const status = response.data.editComment.comment.status;
notifyForNewCommentStatus(this.props.notify, status);
if (typeof stopEditing === 'function') {
stopEditing();
}
} catch (error) {
this.setState({ loadingState: 'error' });
}
};
getEditableUntil = (props = this.props) => {
return getEditableUntilDate(props.comment);
};
isEditWindowExpired = (props = this.props) => {
return this.getEditableUntil(props) - new Date() < 0;
};
isSubmitEnabled = () => this.state.submitEnabled;
render() {
return (
<EditableCommentContent
charCountEnable={this.props.charCountEnable}
submitEnabled={this.isSubmitEnabled}
maxCharCount={this.props.maxCharCount}
root={this.props.root}
comment={this.props.comment}
input={this.state.input}
onInputChange={this.handleInputChange}
onSubmit={this.handleSubmit}
onCancel={this.props.stopEditing}
loadingState={this.state.loadingState}
editWindowExpired={this.isEditWindowExpired()}
editableUntil={this.getEditableUntil()}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
/>
);
}
}
EditableCommentContentContainer.propTypes = {
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
currentUser: PropTypes.object,
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
editComment: PropTypes.func,
stopEditing: PropTypes.func,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
forEachHook: PropTypes.func.isRequired,
};
EditableCommentContentContainer.fragments = CommentForm.fragments;
export default EditableCommentContentContainer;
const enhance = compose(withHooks(['preSubmit', 'postSubmit']));
export default enhance(EditableCommentContentContainer);
@@ -367,6 +367,7 @@ const LOAD_MORE_QUERY = gql`
`;
const slots = [
'commentInputDetailArea',
'streamTabs',
'streamTabsPrepend',
'streamTabPanes',
@@ -465,7 +466,6 @@ const mapStateToProps = state => ({
activeStreamTab: state.stream.activeTab,
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
sortOrder: state.stream.sortOrder,
sortBy: state.stream.sortBy,
});
@@ -0,0 +1,57 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
/**
* WithHooks provides a property `hooks` to the wrapped component.
*/
export default hooks =>
hoistStatics(WrappedComponent => {
class WithHooks extends React.Component {
hooks = hooks.reduce((map, key) => {
map[key] = [];
return map;
}, {});
registerHook = (hookType = '', hook) => {
if (typeof hook !== 'function') {
return console.warn(
`Hooks must be functions. Please check your ${hookType} hooks`
);
}
if (!hooks.includes(hookType)) {
throw new Error(`Unknown hookType ${hookType}`);
}
this.hooks[hookType].push(hook);
return {
hookType,
hook,
};
};
unregisterHook = hookData => {
const { hookType, hook } = hookData;
const idx = this.hooks[hookType].indexOf(hook);
if (idx !== -1) {
this.hooks[hookType].splice(idx, 1);
}
};
forEachHook = (hookType, callback) => {
this.hooks[hookType].forEach(callback);
};
render() {
return (
<WrappedComponent
{...this.props}
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
forEachHook={this.forEachHook}
/>
);
}
}
return WithHooks;
});
+8
View File
@@ -161,6 +161,14 @@ export default class Stream {
);
}
enablePluginsDebug() {
this.pym.sendMessage('enablePluginsDebug');
}
disablePluginsDebug() {
this.pym.sendMessage('disablePluginsDebug');
}
login(token) {
this.pym.sendMessage('login', token);
}
+12
View File
@@ -7,6 +7,10 @@ export default class StreamInterface {
return this._stream.emitter.on(eventName, callback);
}
off(eventName, callback) {
return this._stream.emitter.off(eventName, callback);
}
login(token) {
return this._stream.login(token);
}
@@ -18,4 +22,12 @@ export default class StreamInterface {
remove() {
return this._stream.remove();
}
enablePluginsDebug() {
return this._stream.enablePluginsDebug();
}
disablePluginsDebug() {
return this._stream.disablePluginsDebug();
}
}
+5
View File
@@ -57,6 +57,10 @@ export const setAuthToken = token => (dispatch, _, { localStorage }) => {
localStorage.setItem('token', token);
}
// Dispatch the set auth token action. For some browsers and situations, we
// may not be able to persist the auth token any other way. Keep it in redux!
dispatch({ type: actions.SET_AUTH_TOKEN, token });
dispatch(checkLogin());
};
@@ -87,6 +91,7 @@ export const handleSuccessfulLogin = (user, token) => (
dispatch({
type: actions.HANDLE_SUCCESSFUL_LOGIN,
user,
token,
});
};
+13 -1
View File
@@ -1,6 +1,18 @@
import { MERGE_CONFIG } from '../constants/config';
import {
MERGE_CONFIG,
ENABLE_PLUGINS_DEBUG,
DISABLE_PLUGINS_DEBUG,
} from '../constants/config';
export const mergeConfig = config => ({
type: MERGE_CONFIG,
config,
});
export const enablePluginsDebug = () => ({
type: ENABLE_PLUGINS_DEBUG,
});
export const disablePluginsDebug = () => ({
type: DISABLE_PLUGINS_DEBUG,
});
@@ -0,0 +1,16 @@
.content {
a {
color: #063b9a;
text-decoration: underline;
font-weight: 300;
background-color: #f4ff81;
}
mark {
background-color: #f4ff81;
}
b, strong {
font-weight: 600;
}
}
@@ -0,0 +1,217 @@
import React from 'react';
import PropTypes from 'prop-types';
import matchLinks from '../utils/matchLinks';
import memoize from 'lodash/memoize';
import cn from 'classnames';
import styles from './AdminCommentContent.css';
function escapeHTML(unsafe) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
// generate a regulare expression that catches the `phrases`.
function generateRegExp(phrases) {
const inner = phrases
.map(phrase =>
phrase
.split(/\s+/)
.map(word => escapeRegExp(word))
.join('[\\s"?!.]+')
)
.join('|');
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, 'iu');
} catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, 'i');
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
function getPhrasesRegexp(suspectWords, bannedWords) {
return generateRegExp([...suspectWords, ...bannedWords]);
}
// Memoized version as arguments rarely change.
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
function nl2br(body, keyPrefix) {
const tokens = body.split('\n').reduce((tokens, t, i) => {
if (i !== 0) {
tokens.push(<br key={`${keyPrefix}_${i}`} />);
}
tokens.push(t);
return tokens;
}, []);
return tokens;
}
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
// an array of React Elements.
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = body.split(regexp);
return tokens.map(
(token, i) =>
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
);
}
// markLinks looks for links inside `body` and highlights them by returning
// an array of React Elements.
function markLinks(body, keyPrefix) {
const matches = matchLinks(body);
const content = [];
let index = 0;
if (matches) {
matches.forEach((match, i) => {
content.push(body.substring(index, match.index));
content.push(
<a key={`${keyPrefix}_${i}`} href={match.url} target="_blank">
{match.text}
</a>
);
index = match.lastIndex;
});
}
content.push(body.substring(index));
return content;
}
// markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning
// a HTML string.
function markPhrasesHTML(text, suspectWords, bannedWords) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = text.split(regexp);
if (tokens.length === 1) {
return text;
}
return tokens
.map(
(token, i) =>
i % 3 === 2 ? `<mark>${escapeHTML(token)}</mark>` : escapeHTML(token)
)
.join('');
}
// markHTMLNode manipulates the node by looking for #text nodes and adding markers
// for `supsectWords` and `bannedWords`.
function markHTMLNode(parentNode, suspectWords, bannedWords) {
parentNode.childNodes.forEach(node => {
if (node.nodeName === '#text') {
const newContent = markPhrasesHTML(
node.textContent,
suspectWords,
bannedWords
);
if (newContent !== node.textContent) {
const newNode = document.createElement('span');
newNode.innerHTML = newContent;
parentNode.replaceChild(newNode, node);
}
} else {
markHTMLNode(node, suspectWords, bannedWords);
}
});
}
// renderText performs all the marking of a text body and returns an array of React Elements.
function renderText(body, suspectWords, bannedWords) {
return nl2br(body).map((element, index) => {
// Skip br tags.
if (typeof element !== 'string') {
return element;
}
return markLinks(element, index).map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
});
}
const commonPropTypes = {
className: PropTypes.string,
bannedWords: PropTypes.array.isRequired,
suspectWords: PropTypes.array.isRequired,
body: PropTypes.string.isRequired,
};
const AdminCommentContentText = ({
body,
className,
suspectWords,
bannedWords,
}) => {
return (
<div className={cn(className, styles.content)}>
{renderText(body, suspectWords, bannedWords)}
</div>
);
};
AdminCommentContentText.propTypes = commonPropTypes;
const AdminCommentContentHTML = ({
body,
className,
suspectWords,
bannedWords,
}) => {
// We create a Shadow DOM Tree with the HTML body content and
// use it as a parser.
const node = document.createElement('div');
node.innerHTML = body;
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, suspectWords, bannedWords);
// Finally we render the content of the Shadow DOM Tree
return (
<div
className={cn(className, styles.content)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
/>
);
};
AdminCommentContentHTML.propTypes = commonPropTypes;
const AdminCommentContent = ({
className,
body,
suspectWords,
bannedWords,
html,
}) => {
const Component = html ? AdminCommentContentHTML : AdminCommentContentText;
return (
<Component
className={className}
body={body}
suspectWords={suspectWords}
bannedWords={bannedWords}
/>
);
};
AdminCommentContent.propTypes = {
...commonPropTypes,
html: PropTypes.bool,
};
export default AdminCommentContent;
@@ -1,39 +1,14 @@
import React, { Children } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { getShallowChanges } from 'coral-framework/utils';
import { withSlotElements, withCompatPassthrough } from '../hocs';
import { compose } from 'recompose';
class IfSlotIsEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
isSlotEmpty(props = this.props) {
const {
slot,
className: _a,
reduxState,
component: _b = 'div',
children: _c,
queryData,
...rest
} = props;
const slots = Array.isArray(slot) ? slot : [slot];
return slots.every(slot =>
this.context.plugins.isSlotEmpty(slot, reduxState, rest, queryData)
);
const { slotElements } = props;
return slotElements.length === 0
? false
: slotElements.every(elements => elements.length === 0);
}
render() {
@@ -44,10 +19,15 @@ class IfSlotIsEmpty extends React.Component {
IfSlotIsEmpty.propTypes = {
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
children: PropTypes.node.isRequired,
passthrough: PropTypes.object.isRequired,
};
const mapStateToProps = state => ({
reduxState: state,
});
const omitProps = ['slot', 'children'];
export default connect(mapStateToProps, null)(IfSlotIsEmpty);
export default compose(
withCompatPassthrough(omitProps),
withSlotElements({
slot: props => props.slot,
})
)(IfSlotIsEmpty);
@@ -1,39 +1,14 @@
import React, { Children } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { getShallowChanges } from 'coral-framework/utils';
import { withSlotElements, withCompatPassthrough } from '../hocs';
import { compose } from 'recompose';
class IfSlotIsNotEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
isSlotEmpty(props = this.props) {
const {
slot,
className: _a,
reduxState,
component: _b = 'div',
children: _c,
queryData,
...rest
} = props;
const slots = Array.isArray(slot) ? slot : [slot];
return slots.every(slot =>
this.context.plugins.isSlotEmpty(slot, reduxState, rest, queryData)
);
const { slotElements } = props;
return slotElements.length === 0
? true
: slotElements.every(elements => elements.length === 0);
}
render() {
@@ -44,10 +19,16 @@ class IfSlotIsNotEmpty extends React.Component {
IfSlotIsNotEmpty.propTypes = {
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
slotElements: PropTypes.array.isRequired,
children: PropTypes.node.isRequired,
passthrough: PropTypes.object.isRequired,
};
const mapStateToProps = state => ({
reduxState: state,
});
const omitProps = ['slot', 'children'];
export default connect(mapStateToProps, null)(IfSlotIsNotEmpty);
export default compose(
withCompatPassthrough(omitProps),
withSlotElements({
slot: props => props.slot,
})
)(IfSlotIsNotEmpty);
@@ -1,154 +1,11 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import SimpleMDE from 'simplemde';
import cn from 'classnames';
import noop from 'lodash/noop';
import styles from './MarkdownEditor.css';
import { Spinner } from 'coral-ui';
import Loadable from 'react-loadable';
const config = {
status: false,
const MarkdownEditor = Loadable({
loader: () =>
import(/* webpackChunkName: "markdownEditor" */
'./loadable/MarkdownEditor'),
loading: Spinner,
});
// Do not download fontAwesome icons as we replace them with
// material icons.
autoDownloadFontAwesome: false,
// Disable built-in spell checker as it is very rudimentary.
spellChecker: false,
toolbar: [
{
name: 'bold',
action: SimpleMDE.toggleBold,
className: styles.iconBold,
title: 'Bold',
},
{
name: 'italic',
action: SimpleMDE.toggleItalic,
className: styles.iconItalic,
title: 'Italic',
},
{
name: 'title',
action: SimpleMDE.toggleHeadingSmaller,
className: styles.iconTitle,
title: 'Title, Subtitle, Heading',
},
'|',
{
name: 'quote',
action: SimpleMDE.toggleBlockquote,
className: styles.iconQuote,
title: 'Quote',
},
{
name: 'unordered-list',
action: SimpleMDE.toggleUnorderedList,
className: styles.iconUnorderedList,
title: 'Generic List',
},
{
name: 'ordered-list',
action: SimpleMDE.toggleOrderedList,
className: styles.iconOrderedList,
title: 'Numbered List',
},
'|',
{
name: 'link',
action: SimpleMDE.drawLink,
className: styles.iconLink,
title: 'Create Link',
},
{
name: 'image',
action: SimpleMDE.drawImage,
className: styles.iconImage,
title: 'Insert Image',
},
'|',
{
name: 'preview',
action: SimpleMDE.togglePreview,
className: cn(styles.iconPreview, 'no-disable'),
title: 'Toggle Preview',
},
{
name: 'side-by-side',
action: SimpleMDE.toggleSideBySide,
className: cn(styles.iconSideBySide, 'no-disable'),
title: 'Toggle Side by Side',
},
{
name: 'fullscreen',
action: SimpleMDE.toggleFullScreen,
className: cn(styles.iconFullscreen, 'no-disable'),
title: 'Toggle Fullscreen',
},
'|',
{
name: 'guide',
action: 'https://simplemde.com/markdown-guide',
className: styles.iconGuide,
title: 'Markdown Guide',
},
],
};
export default class MarkdownEditor extends Component {
textarea = null;
editor = null;
onRef = ref => (this.textarea = ref);
componentDidMount() {
this.editor = new SimpleMDE({
...config,
element: this.textarea,
});
// Don't trap the key, to stay accessible.
this.editor.codemirror.options.extraKeys['Tab'] = false;
this.editor.codemirror.options.extraKeys['Shift-Tab'] = false;
this.editor.codemirror.on('change', this.onChange);
}
componentWillReceiveProps(nextProps) {
if (
this.props.value !== nextProps.value &&
nextProps.value !== this.editor.value()
) {
this.editor.value(nextProps.value);
}
}
componentDidUpdate() {
// Workaround empty render issue.
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
this.editor.codemirror.refresh();
}
componentWillUnmount() {
this.editor.toTextArea();
}
onChange = () => {
if (this.props.onChange) {
this.props.onChange(this.editor.value());
}
};
render() {
return (
<div className={styles.wrapper}>
<textarea ref={this.onRef} {...this.props} onChange={noop} />
</div>
);
}
}
MarkdownEditor.propTypes = {
onChange: PropTypes.func,
value: PropTypes.string,
};
export default MarkdownEditor;
+32 -1
View File
@@ -3,5 +3,36 @@
}
.debug {
background-color: coral;
background-color: #e2e2e2;
border-style: dotted solid;
border-width: 2px;
border: dotted 2px coral;
padding: 2px;
margin: 1px;
position: relative;
}
.debug::before {
content: attr(data-slot-name);
display: inline-block;
position: absolute;
background: #000;
color: #FFF;
padding: 5px;
border-radius: 5px;
opacity: 0;
transition: 0.3s;
overflow: hidden;
pointer-events: none;
z-index: 999!important;
white-space: pre-wrap;
min-height: 16px;
top: 50%;
left: 0;
}
.debug:hover::before {
opacity: 1;
top: 100%;
}
+34 -74
View File
@@ -4,86 +4,21 @@ import styles from './Slot.css';
import { connect } from 'react-redux';
import kebabCase from 'lodash/kebabCase';
import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import { getShallowChanges } from 'coral-framework/utils';
import omit from 'lodash/omit';
const emptyConfig = {};
import { withSlotElements, withCompatPassthrough } from '../hocs';
import { compose } from 'recompose';
class Slot extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevChildrenKeys = this.getChildren(this.props).map(
child => child.key
);
const nextChildrenKeys = this.getChildren(next).map(child => child.key);
return !isEqual(prevChildrenKeys, nextChildrenKeys);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
getSlotProps(props = this.props) {
return omit(props, [
'fill',
'inline',
'className',
'reduxState',
'slotSize',
'defaultComponent',
'queryData',
'childFactory',
'component',
]);
}
getChildren(props = this.props) {
const { slotSize = 0 } = props;
const { plugins } = this.context;
return plugins.getSlotElements(
props.fill,
props.reduxState,
this.getSlotProps(props),
props.queryData,
{ slotSize }
);
}
render() {
const {
inline = false,
className,
reduxState,
debug,
component: Component,
childFactory,
defaultComponent: DefaultComponent,
queryData,
fill,
} = this.props;
const { plugins } = this.context;
let children = this.getChildren();
const pluginConfig =
get(reduxState, 'config.plugins_config') || emptyConfig;
if (children.length === 0 && DefaultComponent) {
const props = plugins.getSlotComponentProps(
DefaultComponent,
reduxState,
this.getSlotProps(this.props),
queryData
);
children = <DefaultComponent {...props} />;
}
let children = this.props.slotElements;
if (childFactory) {
children = children.map(childFactory);
}
@@ -91,10 +26,11 @@ class Slot extends React.Component {
return (
<Component
className={cn(
{ [styles.inline]: inline, [styles.debug]: pluginConfig.debug },
{ [styles.inline]: inline, [styles.debug]: debug },
className,
`talk-slot-${kebabCase(fill)}`
)}
data-slot-name={fill}
>
{children}
</Component>
@@ -110,13 +46,14 @@ Slot.propTypes = {
fill: PropTypes.string.isRequired,
inline: PropTypes.bool,
className: PropTypes.string,
reduxState: PropTypes.object,
debug: PropTypes.bool,
slotElements: PropTypes.arrayOf(PropTypes.element).isRequired,
defaultComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
/**
* Specifies the number of children that can fill the slot.
*/
slotSize: PropTypes.number,
size: PropTypes.number,
/**
* You may specify the component to use as the root wrapper.
@@ -125,8 +62,12 @@ Slot.propTypes = {
component: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
// props coming from graphql must be passed through this property.
// @Deprecated
queryData: PropTypes.object,
// props that are passed to all Slot Components
passthrough: 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
@@ -140,8 +81,27 @@ Slot.propTypes = {
childFactory: PropTypes.func,
};
const omitProps = [
'fill',
'inline',
'className',
'size',
'defaultComponent',
'queryData',
'childFactory',
'component',
];
const mapStateToProps = state => ({
reduxState: state,
debug: get(state, 'config.plugins_config.debug'),
});
export default connect(mapStateToProps, null)(Slot);
export default compose(
withCompatPassthrough(omitProps),
withSlotElements({
slot: props => props.fill,
size: props => props.size,
defaultComponent: props => props.defaultComponent,
}),
connect(mapStateToProps, null)
)(Slot);

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