Merge branch 'master' into performance-enhancements

This commit is contained in:
Chi Vinh Le
2017-11-30 22:23:11 +01:00
98 changed files with 1559 additions and 889 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
{
"exceptions": [
"https://nodesecurity.io/advisories/531"
"https://nodesecurity.io/advisories/531",
"https://nodesecurity.io/advisories/532"
]
}
}
+44 -23
View File
@@ -12,7 +12,7 @@ const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
util.onshutdown([
() => mongoose.disconnect()
() => mongoose.disconnect(),
]);
/**
@@ -20,17 +20,17 @@ util.onshutdown([
*/
function processJobs() {
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
}
/**
@@ -48,22 +48,13 @@ function removeJob(job) {
}));
}
/**
* Removes the jobs passed in and returns a promise.
* @param {Array} jobs array of jobs
* @return {Promise}
*/
function removeJobs(jobs) {
return Promise.all(jobs.map(removeJob));
}
/**
* Get the top n jobs with a specific state.
* @param {String} [state='complete'] state to list jobs by
* @param {Number} limit limit of jobs to load
* @return {Promise}
*/
function rangeJobsByState(state = 'complete', limit) {
function rangeJobsByState(state, limit) {
return new Promise((resolve, reject) => {
kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => {
if (err) {
@@ -75,22 +66,52 @@ function rangeJobsByState(state = 'complete', limit) {
});
}
async function getJobBatch(n, includeStuck) {
let jobs = [];
jobs = await rangeJobsByState('complete', n);
if (includeStuck) {
jobs = jobs.concat(await rangeJobsByState('failed', n));
}
return jobs;
}
/**
* Cleans up the jobs that are in the queue.
*/
async function cleanupJobs(options) {
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
const n = 100;
try {
const joblists = await Promise.all([
rangeJobsByState('complete', n),
options.stuck ? rangeJobsByState('failed', n) : false
]);
await joblists.filter((jobs) => jobs).map(removeJobs);
// Connect to redis by establishing a queue.
kue.Task.connect();
let jobCount = 0;
let jobs = await getJobBatch(n, options.stuck);
while (jobs.length > 0) {
// Remove all the jobs.
await Promise.all(jobs.map((job) => removeJob(job)));
jobCount += jobs.length;
// Get the next batch of jobs.
jobs = await getJobBatch(n, options.stuck);
}
util.shutdown();
console.log('Removed old jobs');
console.log(`Removed ${jobCount} jobs`);
} catch (err) {
console.error(err);
util.shutdown(1);
+39 -14
View File
@@ -8,10 +8,13 @@ const program = require('./commander');
const inquirer = require('inquirer');
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');
const util = require('./util');
const Table = require('cli-table');
const databaseVerifications = require('./verifications/database');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
if (input && input.length >= len) {
@@ -122,26 +125,48 @@ async function createUser(options) {
} catch (err) {
console.error(err);
util.shutdown();
util.shutdown(1);
}
}
/**
* Deletes a user.
*/
function deleteUser(userID) {
UserModel
.findOneAndRemove({
id: userID
})
.then(() => {
console.log('Deleted user');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
async function deleteUser(userID) {
try {
// Find the user we're removing.
const user = await UserModel.findOne({id: userID});
if (!user) {
throw new Error(`user with id ${userID} not found`);
}
// Remove all the user's actions.
await ActionModel
.where({user_id: user.id})
.setOptions({multi: true})
.remove();
// Remove all the user's comments.
await CommentModel
.where({author_id: user.id})
.setOptions({multi: true})
.remove();
// Update the counts that might have changed.
for (const verification of databaseVerifications) {
await verification({fix: true, limit: Infinity, batch: 1000});
}
// Remove the user.
await user.remove();
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
/**
@@ -87,7 +87,7 @@ const CoralHeader = ({
</a>
</MenuItem>
<MenuItem>
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank" rel="noopener noreferrer">
<a href="https://support.coralproject.net" target="_blank" rel="noopener noreferrer">
Report a bug or give feedback
</a>
</MenuItem>
+1 -11
View File
@@ -1,15 +1,5 @@
import update from 'immutability-helper';
import mapValues from 'lodash/mapValues';
// Map nested object leaves. Array objects are considered leaves.
function mapLeaves(o, mapper) {
return mapValues(o, (val) => {
if (typeof val === 'object' && !Array.isArray(val)) {
return mapLeaves(val, mapper);
}
return mapper(val);
});
}
import {mapLeaves} from 'coral-framework/utils';
export default {
mutations: {
@@ -39,7 +39,7 @@ class ModerationSettings extends React.Component {
};
render() {
const {settings, data, root} = this.props;
const {settings, data, root, updatePending, errors} = this.props;
return (
<ConfigurePage
@@ -74,6 +74,8 @@ class ModerationSettings extends React.Component {
fill="adminModerationSettings"
data={data}
queryData={{root, settings}}
updatePending={updatePending}
errors={errors}
/>
</ConfigurePage>
);
@@ -82,6 +84,7 @@ 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,
@@ -53,3 +53,6 @@
.autoCloseWrapper {
display: flex;
}
@@ -100,7 +100,7 @@ class StreamSettings extends React.Component {
};
render() {
const {settings, data, root, errors} = this.props;
const {settings, data, root, errors, updatePending} = this.props;
return (
<ConfigurePage
@@ -180,22 +180,24 @@ class StreamSettings extends React.Component {
onCheckbox={this.updateAutoClose}
title={t('configure.close_after')}
>
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={this.updateClosedTimeout}
value={getTimeoutAmount(settings.closedTimeout)}
label={t('configure.closed_comments_label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={this.updateClosedTimeoutMeasure}>
<Option value={'hours'}>{t('configure.hours')}</Option>
<Option value={'days'}>{t('configure.days')}</Option>
<Option value={'weeks'}>{t('configure.weeks')}</Option>
</SelectField>
<div className={styles.autoCloseWrapper}>
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={this.updateClosedTimeout}
value={getTimeoutAmount(settings.closedTimeout)}
label={t('configure.closed_comments_label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={this.updateClosedTimeoutMeasure}>
<Option value={'hours'}>{t('configure.hours')}</Option>
<Option value={'days'}>{t('configure.days')}</Option>
<Option value={'weeks'}>{t('configure.weeks')}</Option>
</SelectField>
</div>
</div>
</ConfigureCard>
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
@@ -203,6 +205,8 @@ class StreamSettings extends React.Component {
fill="adminStreamSettings"
data={data}
queryData={{root, settings}}
updatePending={updatePending}
errors={errors}
/>
</ConfigurePage>
);
@@ -31,7 +31,7 @@ class TechSettings extends React.Component {
};
render() {
const {settings, data, root} = this.props;
const {settings, data, root, errors, updatePending} = this.props;
return (
<ConfigurePage
title={t('configure.tech_settings')}
@@ -51,6 +51,8 @@ class TechSettings extends React.Component {
fill="adminTechSettings"
data={data}
queryData={{root, settings}}
updatePending={updatePending}
errors={errors}
/>
</ConfigurePage>
);
@@ -59,6 +61,7 @@ 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,
@@ -2,38 +2,20 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import {withQuery, withMergedSettings} from 'coral-framework/hocs';
import {Spinner} from 'coral-ui';
import {notify} from 'coral-framework/actions/notification';
import PropTypes from 'prop-types';
import assignWith from 'lodash/assignWith';
import {withUpdateSettings} from 'coral-framework/graphql/mutations';
import {getErrorMessages, getDefinitionName} from 'coral-framework/utils';
import StreamSettings from './StreamSettings';
import TechSettings from './TechSettings';
import ModerationSettings from './ModerationSettings';
import {clearPending, setActiveSection} from '../../../actions/configure';
import Configure from '../components/Configure';
// Like lodash merge but does not recurse into arrays.
const mergeExcludingArrays = (objValue, srcValue) => {
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
}
return srcValue;
};
class ConfigureContainer extends Component {
// Merge current settings with pending settings.
getMergedSettings = (props = this.props) => {
return assignWith({}, props.root.settings, props.pending, mergeExcludingArrays);
}
// Cached merged settings.
mergedSettings = this.getMergedSettings();
savePending = async () => {
try {
await this.props.updateSettings(this.props.pending);
@@ -44,14 +26,6 @@ class ConfigureContainer extends Component {
}
};
componentWillReceiveProps(nextProps) {
// Recalculate merged settings when necessary.
if (this.props.root.settings !== nextProps.root.settings || this.props.pending !== nextProps.pending) {
this.mergedSettings = this.getMergedSettings(nextProps);
}
}
render () {
if(this.props.data.loading) {
return <Spinner/>;
@@ -62,7 +36,7 @@ class ConfigureContainer extends Component {
auth={this.props.auth}
data={this.props.data}
root={this.props.root}
settings={this.mergedSettings}
settings={this.props.mergedSettings}
canSave={this.props.canSave}
savePending={this.savePending}
setActiveSection={this.props.setActiveSection}
@@ -112,6 +86,7 @@ export default compose(
withUpdateSettings,
withConfigureQuery,
connect(mapStateToProps, mapDispatchToProps),
withMergedSettings('root.settings', 'pending', 'mergedSettings'),
)(ConfigureContainer);
ConfigureContainer.propTypes = {
@@ -124,5 +99,6 @@ ConfigureContainer.propTypes = {
root: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
pending: PropTypes.object.isRequired,
mergedSettings: PropTypes.object.isRequired,
activeSection: PropTypes.string.isRequired,
};
@@ -10,6 +10,10 @@ const slots = [
'adminModerationSettings',
];
const mapStateToProps = (state) => ({
errors: state.configure.errors,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
updatePending,
@@ -36,5 +40,5 @@ export default compose(
}
`
}),
connect(null, mapDispatchToProps),
connect(mapStateToProps, mapDispatchToProps),
)(ModerationSettings);
@@ -10,6 +10,10 @@ const slots = [
'adminTechSettings',
];
const mapStateToProps = (state) => ({
errors: state.configure.errors,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
updatePending,
@@ -33,5 +37,5 @@ export default compose(
}
`
}),
connect(null, mapDispatchToProps),
connect(mapStateToProps, mapDispatchToProps),
)(TechSettings);
@@ -48,7 +48,7 @@ class Stories extends Component {
<div className={styles.optionHeader}>{t('streams.filter_streams')}</div>
<div className={styles.optionDetail}>{t('streams.stream_status')}</div>
<RadioGroup
name='status filter'
name='statusFilter'
value={filter}
childContainer='div'
onChange={onSettingChange('filter')}
@@ -60,7 +60,7 @@ class Stories extends Component {
</RadioGroup>
<div className={styles.optionHeader}>{t('streams.sort_by')}</div>
<RadioGroup
name='sort by'
name='sortBy'
value={asc}
childContainer='div'
onChange={onSettingChange('asc')}
@@ -1,29 +0,0 @@
import React from 'react';
import {Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
const CloseCommentsInfo = ({status, onClick}) => (
status === 'open' ? (
<div className="close-comments-intro-wrapper">
<p>
{t('configure.open_stream_configuration')}
</p>
<Button onClick={onClick}>{t('configure.close_stream')}</Button>
</div>
) : (
<div className="close-comments-intro-wrapper">
<p>
{t('configure.close_stream_configuration')}
</p>
<Button onClick={onClick}>{t('configure.open_stream')}</Button>
</div>
)
);
CloseCommentsInfo.propTypes = {
status: PropTypes.string,
onClick: PropTypes.func,
};
export default CloseCommentsInfo;
@@ -1,71 +0,0 @@
import React from 'react';
import {Button, Checkbox} from 'coral-ui';
import QuestionBoxBuilder from './QuestionBoxBuilder';
import cn from 'classnames';
import styles from './ConfigureCommentStream.css';
import t from 'coral-framework/services/i18n';
export default ({handleChange, handleApply, changed, ...props}) => (
<form onSubmit={handleApply}>
<div className={styles.wrapper}>
<div className={styles.container}>
<h3>{t('configure.title')}</h3>
<Button
type="submit"
className={cn(styles.apply, 'talk-embed-stream-configuration-submit-button')}
onChange={handleChange}
cStyle={changed ? 'green' : 'darkGrey'} >
{t('configure.apply')}
</Button>
<p>{t('configure.description')}</p>
</div>
<ul>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premod"
onChange={handleChange}
defaultChecked={props.premod}
info={{
title: t('configure.enable_premod'),
description: t('configure.enable_premod_description')
}} />
</li>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="plinksenable"
onChange={handleChange}
defaultChecked={props.premodLinksEnable}
info={{
title: t('configure.enable_premod_links'),
description: t('configure.enable_premod_links_description')
}} />
</li>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="qboxenable"
onChange={handleChange}
defaultChecked={props.questionBoxEnable}
info={{
title: t('configure.enable_questionbox'),
description: t('configure.enable_questionbox_description')
}} />
{
props.questionBoxEnable && <QuestionBoxBuilder
questionBoxIcon={props.questionBoxIcon}
questionBoxContent={props.questionBoxContent}
handleChange={handleChange}
/>
}
</li>
</ul>
</div>
</form>
);
@@ -1,24 +0,0 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import marked from 'marked';
const renderer = new marked.Renderer();
// Set link target to `_parent` to work properly in an embed.
renderer.link = (href, title, text) =>
`<a target="_parent" href="${href}" ${title ? `title="${title}"` : ''}>${text}</a>`;
marked.setOptions({renderer});
export default class Markdown extends PureComponent {
render() {
const {content, ...rest} = this.props;
const __html = marked(content);
return <div {...rest} dangerouslySetInnerHTML={{__html}} />;
}
}
Markdown.propTypes = {
content: PropTypes.string,
};
@@ -1,45 +0,0 @@
.qbBuilder {
margin-left: 50px;
}
.qbItemIconList {
padding: 0;
margin: 10px 0;
}
.qbItemIcon {
background: #F0F0F0;
width: 45px;
height: 45px;
font-size: 24px;
text-align: center;
line-height: 45px;
color: #252525;
border-radius: 3px;
display: inline-block;
overflow: hidden;
margin-right: 10px;
position: relative;
border: solid 2px #F0F0F0;
transition: border 0.3s cubic-bezier(.4,0,.2,1);
}
.qbItemIcon:hover {
cursor: pointer;
}
.qbItemIconActive {
border: solid 2px #00796B;
}
.defaultIcon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.qb {
margin: 10px 0;
}
@@ -1,98 +0,0 @@
import React from 'react';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import {Icon, Spinner} from 'coral-ui';
import DefaultIcon from './DefaultIcon';
import cn from 'classnames';
import styles from './QuestionBoxBuilder.css';
class QuestionBoxBuilder extends React.Component {
constructor() {
super();
this.state = {
loading: true
};
}
componentWillMount() {
this.loadEditor();
}
async loadEditor() {
const {default: MarkdownEditor} = await import('coral-framework/components/MarkdownEditor');
return this.setState({
loading : false,
MarkdownEditor
});
}
render() {
const {handleChange, questionBoxIcon, questionBoxContent} = this.props;
const {loading, MarkdownEditor} = this.state;
if (loading) {
return <Spinner/>;
}
return (
<div className={styles.qbBuilder}>
<h4>Include an Icon</h4>
<ul className={styles.qbItemIconList}>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'default'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="default" >
<DefaultIcon className={styles.defaultIcon} />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'forum'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="forum" >
<Icon name="forum" />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'build'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="build" >
<Icon name="build" />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'format_quote'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="format_quote" >
<Icon name="format_quote" />
</li>
</ul>
<QuestionBox
className={styles.qb}
enable={true}
icon={questionBoxIcon}
content={questionBoxContent}
/>
<MarkdownEditor
value={questionBoxContent}
onChange={(value) => handleChange({}, {questionBoxContent: value})}
/>
</div>
);
}
}
export default QuestionBoxBuilder;
@@ -1,145 +0,0 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {compose} from 'react-apollo';
import PropTypes from 'prop-types';
import {updateOpenStatus, updateConfiguration} from 'coral-embed-stream/src/actions/asset';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
import t, {timeago} from 'coral-framework/services/i18n';
class ConfigureStreamContainer extends Component {
constructor (props) {
super(props);
this.state = {
changed: false,
dirtySettings: {...props.asset.settings},
closedAt: !props.asset.isClosed ? 'open' : 'closed'
};
this.toggleStatus = this.toggleStatus.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleApply = this.handleApply.bind(this);
}
handleApply (e) {
e.preventDefault();
const {elements} = e.target;
const {questionBoxIcon, questionBoxContent} = this.state.dirtySettings;
const premod = elements.premod.checked;
const questionBoxEnable = elements.qboxenable.checked;
const premodLinksEnable = elements.plinksenable.checked;
const {changed} = this.state;
const newConfig = {
moderation: premod ? 'PRE' : 'POST',
questionBoxEnable,
questionBoxContent,
questionBoxIcon,
premodLinksEnable
};
if (changed) {
this.props.updateConfiguration(newConfig);
setTimeout(() => {
this.setState({
changed: false
});
}, 300);
}
}
handleChange (e, newChanges) {
let changes = {};
if (changes) {
changes = {...newChanges};
}
if (e.target && e.target.id === 'qboxenable') {
changes.questionBoxEnable = e.target.checked;
}
if (e.currentTarget && e.currentTarget.id === 'qboxicon') {
changes.questionBoxIcon = e.currentTarget.dataset.icon;
}
if (e.target && e.target.id === 'plinksenable') {
changes.premodLinksEnable = e.target.value;
}
this.setState({
changed: true,
dirtySettings: {
...this.state.dirtySettings,
...changes,
},
});
}
toggleStatus () {
// update the closedAt status for the asset
this.props.updateStatus(
this.state.closedAt === 'open' ? 'closed' : 'open'
);
this.setState({
closedAt: (this.state.closedAt === 'open' ? 'closed' : 'open')
});
}
getClosedIn () {
const {closedTimeout} = this.props.asset.settings;
const {created_at} = this.props.asset;
return timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
}
render () {
const {dirtySettings} = this.state;
const premod = dirtySettings.moderation === 'PRE';
const {closedAt} = this.state;
const closedTimeout = dirtySettings.closedTimeout;
return (
<div className='talk-embed-stream-configuration-container'>
<ConfigureCommentStream
handleChange={this.handleChange}
handleApply={this.handleApply}
changed={this.state.changed}
premodLinksEnable={dirtySettings.premodLinksEnable}
premod={premod}
questionBoxIcon={dirtySettings.questionBoxIcon}
questionBoxEnable={dirtySettings.questionBoxEnable}
questionBoxContent={dirtySettings.questionBoxContent}
/>
<hr />
<h3>{closedAt === 'open' ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}</h3>
{(closedAt === 'open' && closedTimeout) ? <p>{t('configure.comment_stream_will_close')} {this.getClosedIn()}.</p> : ''}
<CloseCommentsInfo
onClick={this.toggleStatus}
status={closedAt}
/>
</div>
);
}
}
const mapStateToProps = (state) => ({
asset: state.asset
});
const mapDispatchToProps = (dispatch) => ({
updateStatus: (status) => dispatch(updateOpenStatus(status)),
updateConfiguration: (newConfig) => dispatch(updateConfiguration(newConfig)),
});
ConfigureStreamContainer.propTypes = {
updateStatus: PropTypes.func,
closedTimeout: PropTypes.string,
created_at: PropTypes.string,
updateConfiguration: PropTypes.func,
asset: PropTypes.object,
};
export default compose(
connect(mapStateToProps, mapDispatchToProps)
)(ConfigureStreamContainer);
@@ -0,0 +1,9 @@
import * as actions from '../constants/configure';
export const updatePending = ({updater, errorUpdater}) => {
return {type: actions.UPDATE_PENDING, updater, errorUpdater};
};
export const clearPending = () => {
return {type: actions.CLEAR_PENDING};
};
@@ -16,12 +16,5 @@
color: #262626;
}
.qbIconContainer {
position: relative;
border: 0;
color: white;
display: inline-block;
padding: 5px 20px;
vertical-align: middle;
width: 10px;
}
.root {
}
@@ -1,13 +1,13 @@
import React from 'react';
import cn from 'classnames';
import styles from './DefaultIcon.css';
import styles from './DefaultQuestionBoxIcon.css';
import {Icon} from 'coral-ui';
const DefaultIcon = ({className}) => (
<div className={cn(styles.qbIconContainer, className)}>
const DefaultQuestionBoxIcon = ({className}) => (
<div className={cn(styles.root, className)}>
<Icon name="chat_bubble" className={cn(styles.iconBubble)} />
<Icon name="person" className={cn(styles.iconPerson)} />
</div>
);
export default DefaultIcon;
export default DefaultQuestionBoxIcon;
@@ -1,16 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import Stream from '../containers/Stream';
import Stream from '../tabs/stream/containers/Stream';
import Configure from '../tabs/configure/containers/Configure';
import Slot from 'coral-framework/components/Slot';
import {can} from 'coral-framework/services/perms';
import t from 'coral-framework/services/i18n';
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
import {TabBar, Tab, TabContent, TabPane} from 'coral-ui';
import ExtendableTabPanel from '../containers/ExtendableTabPanel';
import {Tab, TabPane} from 'coral-ui';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
import Popup from 'coral-framework/components/Popup';
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
import ConfigureStreamContainer
from 'coral-configure/containers/ConfigureStreamContainer';
import cn from 'classnames';
export default class Embed extends React.Component {
@@ -21,20 +22,37 @@ export default class Embed extends React.Component {
case 'profile':
this.props.data.refetch();
break;
case 'config':
this.props.data.refetch();
break;
}
this.props.setActiveTab(tab);
};
render() {
const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props;
getTabs() {
const {user} = this.props.auth;
const tabs = [
<Tab key='stream' tabId='stream' className='talk-embed-stream-comments-tab'>
{t('embed_comments_tab')}
</Tab>,
<Tab key='profile' tabId='profile' className='talk-embed-stream-profile-tab'>
{t('framework.my_profile')}
</Tab>,
];
if (can(user, 'UPDATE_CONFIG')) {
tabs.push(
<Tab key='config' tabId='config'>
{t('framework.configure_stream')}
</Tab>
);
}
return tabs;
}
render() {
const {activeTab, commentId, root, root: {asset}, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props;
const hasHighlightedComment = !!commentId;
return (
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
<AutomaticAssetClosure asset={asset} />
<IfSlotIsNotEmpty slot="login">
<Popup
href={`embed/stream/login?parentUrl=${encodeURIComponent(parentUrl)}`}
@@ -47,44 +65,36 @@ export default class Embed extends React.Component {
onClose={hideSignInDialog}
/>
</IfSlotIsNotEmpty>
<TabBar
onTabClick={this.changeTab}
activeTab={activeTab}
className='talk-embed-stream-tab-bar'
aria-controls='talk-embed-stream-tab-content'
>
<Tab tabId={'stream'} className={'talk-embed-stream-comments-tab'}>
{t('embed_comments_tab')}
</Tab>
<Tab tabId={'profile'} className={'talk-embed-stream-profile-tab'}>
{t('framework.my_profile')}
</Tab>
{can(user, 'UPDATE_CONFIG') &&
<Tab tabId={'config'} className={'talk-embed-stream-configuration-tab'}>
{t('framework.configure_stream')}
</Tab>
}
</TabBar>
<Slot
data={data}
queryData={{root}}
fill="embed"
/>
<TabContent
<ExtendableTabPanel
className='talk-embed-stream-tab-bar'
activeTab={activeTab}
id='talk-embed-stream-tab-content'
>
<TabPane tabId={'stream'} className={'talk-embed-stream-comments-tab-pane'}>
<Stream data={data} root={root} />
</TabPane>
<TabPane tabId={'profile'} className={'talk-embed-stream-profile-tab-pane'}>
<ProfileContainer />
</TabPane>
<TabPane tabId={'config'} className={'talk-embed-stream-configuration-tab-pane'}>
<ConfigureStreamContainer />
</TabPane>
</TabContent>
setActiveTab={this.changeTab}
fallbackTab='stream'
tabSlot='embedStreamTabs'
tabSlotPrepend='embedStreamTabsPrepend'
tabPaneSlot='embedStreamTabPanes'
slotProps={{data}}
queryData={{root}}
tabs={this.getTabs()}
tabPanes={[
<TabPane key='stream' tabId='stream' className='talk-embed-stream-comments-tab-pane'>
<Stream data={data} root={root} asset={root.asset} />
</TabPane>,
<TabPane key='profile' tabId='profile' className='talk-embed-stream-profile-tab-pane'>
<ProfileContainer />
</TabPane>,
<TabPane key='config' tabId='config' className='talk-embed-stream-configuration-tab-pane'>
<Configure data={data} root={root} asset={root.asset} />
</TabPane>,
]}
/>
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import {Tab} from 'coral-ui';
import PropTypes from 'prop-types';
/**
* ExtendableTab adds a hover property to its children, because
* Tab is rendered as a button and under Firefox its children do
* not support mouse events.
*/
class ExtendableTab extends React.Component {
state = {
hover: false,
}
handleMouseEnter = () => this.setState({hover: true});
handleMouseLeave = () => this.setState({hover: false});
render() {
return (
<Tab
{...this.props}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
{React.cloneElement(this.props.children, {hover: this.state.hover})}
</Tab>
);
}
}
ExtendableTab.propTypes = {
children: PropTypes.node,
};
export default ExtendableTab;
@@ -1,9 +1,9 @@
import React from 'react';
import {Spinner, TabBar, TabContent} from 'coral-ui';
import PropTypes from 'prop-types';
import styles from './StreamTabPanel.css';
import styles from './ExtendableTabPanel.css';
class StreamTabPanel extends React.Component {
class ExtendableTabPanel extends React.Component {
render() {
const {activeTab, setActiveTab, tabs, tabPanes, sub, loading, ...rest} = this.props;
@@ -23,7 +23,7 @@ class StreamTabPanel extends React.Component {
}
}
StreamTabPanel.propTypes = {
ExtendableTabPanel.propTypes = {
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
loading: PropTypes.bool,
@@ -39,4 +39,4 @@ StreamTabPanel.propTypes = {
sub: PropTypes.bool,
};
export default StreamTabPanel;
export default ExtendableTabPanel;
@@ -13,6 +13,8 @@
min-height: 50px;
display: flex;
border-radius: 3px;
padding-right: 40px;
box-sizing: border-box;
}
.icon {
@@ -63,8 +65,3 @@
justify-content: center;
font-weight: 400;
}
.hidden {
visibility: hidden;
display: none;
}
@@ -3,16 +3,14 @@ import cn from 'classnames';
import styles from './QuestionBox.css';
import {Icon} from 'coral-ui';
import Markdown from 'coral-framework/components/Markdown';
import DefaultQuestionBoxIcon from './DefaultQuestionBoxIcon';
import Slot from 'coral-framework/components/Slot';
const QuestionBox = ({content, enable, icon = '', className = ''}) => (
<div className={cn(styles.qbInfo, {[styles.hidden]: !enable}, 'questionbox-info', className)}>
const QuestionBox = ({content, icon, className, children}) => (
<div className={cn(styles.qbInfo, 'questionbox-info', className)}>
{
icon === 'default' ? (
<div className={cn(styles.qbIconContainer)}>
<Icon name="chat_bubble" className={cn(styles.iconBubble)} />
<Icon name="person" className={cn(styles.iconPerson)} />
<DefaultQuestionBoxIcon />
</div>
) : (
<div className={cn(styles.qbIconContainer)}>
@@ -23,8 +21,7 @@ const QuestionBox = ({content, enable, icon = '', className = ''}) => (
<div className={cn(styles.qbContent, 'questionbox-content')}>
<Markdown content={content} />
</div>
<Slot fill="streamQuestionArea" />
{children}
</div>
);
@@ -0,0 +1,4 @@
const prefix = 'TALK_EMBED_STREAM_CONFIGURE';
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
@@ -1,10 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import {gql} from 'react-apollo';
import {gql, compose} from 'react-apollo';
import {withFragments} from 'coral-framework/hocs';
const FRAGMENT = gql`
fragment CoralEmbedStream_AutomaticAssetClosure_Fragment on Asset {
id
isClosed
}
`;
@@ -26,15 +26,15 @@ class AutomaticAssetClosure extends React.Component {
timer = null;
componentWillMount() {
this.setupTimer(this.props.assetId, this.props.closedAt);
this.setupTimer(this.props.asset.id, this.props.asset.closedAt);
}
componentWillReceiveProps(next) {
if (
this.props.assetId !== next.assetId ||
this.props.closedAt !== next.closedAt
this.props.asset.id !== next.asset.id ||
this.props.asset.closedAt !== next.asset.closedAt
) {
this.setupTimer(next.assetId, next.closedAt);
this.setupTimer(next.asset.id, next.asset.closedAt);
}
}
@@ -43,6 +43,7 @@ class AutomaticAssetClosure extends React.Component {
fragment: FRAGMENT,
id: getFragmentId(assetId),
data: {
__typename: 'Asset',
isClosed: true,
},
});
@@ -74,9 +75,21 @@ class AutomaticAssetClosure extends React.Component {
}
}
AutomaticAssetClosure.PropTypes = {
assetId: PropTypes.string,
closedAt: PropTypes.string,
AutomaticAssetClosure.propTypes = {
asset: PropTypes.object.isRequired,
};
export default AutomaticAssetClosure;
const withAutomaticAssetClosureFragments = withFragments({
asset: gql`
fragment CoralEmbedStream_AutomaticAssetClosure_asset on Asset {
id
closedAt
}
`,
});
const enhance = compose(
withAutomaticAssetClosureFragments,
);
export default enhance(AutomaticAssetClosure);
@@ -13,7 +13,9 @@ import * as assetActions from '../actions/asset';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from './Stream';
import Stream from '../tabs/stream/containers/Stream';
import AutomaticAssetClosure from './AutomaticAssetClosure';
import Configure from '../tabs/configure/containers/Configure';
import {notify} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
@@ -151,6 +153,9 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
const slots = [
'embed',
'embedStreamTabs',
'embedStreamTabsPrepend',
'embedStreamTabPanes',
];
const EMBED_QUERY = gql`
@@ -167,10 +172,20 @@ const EMBED_QUERY = gql`
id
status
}
asset(id: $assetId, url: $assetUrl) {
...${getDefinitionName(Configure.fragments.asset)}
...${getDefinitionName(Stream.fragments.asset)}
...${getDefinitionName(AutomaticAssetClosure.fragments.asset)}
}
${getSlotFragmentSpreads(slots, 'root')}
...${getDefinitionName(Stream.fragments.root)}
...${getDefinitionName(Configure.fragments.root)}
}
${Stream.fragments.root}
${Stream.fragments.asset}
${Configure.fragments.root}
${Configure.fragments.asset}
${AutomaticAssetClosure.fragments.asset}
`;
export const withEmbedQuery = withQuery(EMBED_QUERY, {
@@ -1,22 +1,23 @@
import React from 'react';
import StreamTabPanel from '../components/StreamTabPanel';
import ExtendableTabPanel from '../components/ExtendableTabPanel';
import {connect} from 'react-redux';
import {Tab, TabPane} from 'coral-ui';
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';
class StreamTabPanelContainer extends React.Component {
class ExtendableTabPanelContainer extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
componentDidMount() {
this.fallbackAllTab();
this.handleFallback();
}
componentWillReceiveProps(next) {
this.fallbackAllTab(next);
this.handleFallback(next);
}
shouldComponentUpdate(next) {
@@ -34,30 +35,50 @@ class StreamTabPanelContainer extends React.Component {
return changes.length !== 0;
}
fallbackAllTab(props = this.props) {
if (props.activeTab !== props.fallbackTab) {
const slotPlugins = this.getSlotElements(props.tabSlot, props).map((el) => el.type.talkPluginName);
if (slotPlugins.indexOf(props.activeTab) === -1) {
props.setActiveTab(props.fallbackTab);
}
handleFallback(props = this.props) {
if (this.getTabNames(props).indexOf(props.activeTab) === -1) {
props.setActiveTab(props.fallbackTab);
}
}
getTabNames(props = this.props) {
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.getSlotElements(props.tabSlot).map((el) => {
return this.getSlotTabElements(props.tabSlot);
}
getPluginTabElementsPrepend(props = this.props) {
return this.getSlotTabElements(props.tabSlotPrepend);
}
getSlotTabElements(slot) {
return this.getSlotElements(slot).map((el) => {
return (
<Tab tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
<ExtendableTab tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
{React.cloneElement(el, {active: this.props.activeTab === el.type.talkPluginName})}
</Tab>
</ExtendableTab>
);
});
}
getTabElements(props = this.props) {
const elements = [...this.getPluginTabElementsPrepend(props)];
if (Array.isArray(props.tabs)) {
elements.push(...props.tabs);
} else {
elements.push(props.tabs);
}
elements.push(...this.getPluginTabElements(props));
return elements;
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotElements(props.tabPaneSlot).map((el) => {
return (
@@ -70,12 +91,12 @@ class StreamTabPanelContainer extends React.Component {
render() {
return (
<StreamTabPanel
<ExtendableTabPanel
className={this.props.className}
activeTab={this.props.activeTab}
setActiveTab={this.props.setActiveTab}
tabs={this.getPluginTabElements().concat(this.props.appendTabs)}
tabPanes={this.getPluginTabPaneElements().concat(this.props.appendTabPanes)}
tabs={this.getTabElements()}
tabPanes={this.getPluginTabPaneElements().concat(this.props.tabPanes)}
loading={this.props.loading}
sub={this.props.sub}
/>
@@ -83,19 +104,20 @@ class StreamTabPanelContainer extends React.Component {
}
}
StreamTabPanelContainer.propTypes = {
ExtendableTabPanelContainer.propTypes = {
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
appendTabs: PropTypes.oneOfType([
tabs: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
appendTabPanes: PropTypes.oneOfType([
tabPanes: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
fallbackTab: PropTypes.string.isRequired,
tabSlot: PropTypes.string.isRequired,
tabSlotPrepend: PropTypes.string.isRequired,
tabPaneSlot: PropTypes.string.isRequired,
slotProps: PropTypes.object.isRequired,
queryData: PropTypes.object,
@@ -108,4 +130,4 @@ const mapStateToProps = (state) => ({
reduxState: state,
});
export default connect(mapStateToProps, null)(StreamTabPanelContainer);
export default connect(mapStateToProps, null)(ExtendableTabPanelContainer);
@@ -2,6 +2,7 @@ import {gql} from 'react-apollo';
import update from 'immutability-helper';
import uuid from 'uuid/v4';
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
import {mapLeaves} from 'coral-framework/utils';
export default {
fragments: {
@@ -218,6 +219,18 @@ export default {
},
},
}),
UpdateAssetSettings: ({variables: {input}}) => ({
updateQueries: {
CoralEmbedStream_Embed: (prev) => {
const updated = update(prev, {
asset: {
settings: mapLeaves(input, (leaf) => ({$set: leaf})),
},
});
return updated;
}
}
}),
},
};
@@ -0,0 +1,42 @@
import * as actions from '../constants/configure';
import isEmpty from 'lodash/isEmpty';
import update from 'immutability-helper';
const initialState = {
canSave: false,
pending: {},
errors: {},
};
export default function config(state = initialState, action) {
switch (action.type) {
case actions.UPDATE_PENDING: {
let next = state;
if (action.updater) {
next = update(next, {
pending: action.updater,
});
}
if (action.errorUpdater) {
next = update(next, {
errors: action.errorUpdater,
});
}
const noErrors = Object.keys(next.errors).reduce((res, error) => res && !next.errors[error], true);
const canSave = !isEmpty(next.pending) && noErrors;
next = update(next, {
canSave: {$set: canSave},
});
return next;
}
case actions.CLEAR_PENDING:
return {
...state,
pending: {},
canSave: false,
};
default:
return state;
}
}
@@ -2,6 +2,7 @@ import auth from './auth';
import asset from './asset';
import embed from './embed';
import config from './config';
import configure from './configure';
import stream from './stream';
import {reducer as commentBox} from '../../../talk-plugin-commentbox';
@@ -11,5 +12,6 @@ export default {
commentBox,
embed,
config,
configure,
stream,
};
@@ -0,0 +1,10 @@
.wrapper {
display: flex;
justify-content: space-between;
align-items: center;
}
.button {
width: 300px;
margin-left: 20px;
}
@@ -0,0 +1,72 @@
import React from 'react';
import {Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t, {timeago} from 'coral-framework/services/i18n';
import cn from 'classnames';
import styles from './AssetStatusInfo.css';
class AssetStatusInfo extends React.Component {
timer = null;
constructor(props) {
super(props);
this.setupTimer(props);
}
componentWillReceiveProps(nextProps) {
this.setupTimer(nextProps);
}
// Rerendering interval. If remaining time > 1min, rerender every minute, otherwise evey second.
interval(closedAt) {
const diff = new Date(closedAt).getTime() - new Date().getTime();
return diff > 60000 ? 60000 : 1000;
}
// Timer that counts down the remaining time.
setupTimer({closedAt, isClosed} = this.props) {
if (this.timer && (isClosed || !closedAt)) {
clearTimeout(this.timer);
this.timer = null;
}
if (isClosed || !closedAt) {
this.timer = null;
return;
}
if (!this.timer) {
this.timer = setTimeout(() => {
this.timer = null;
this.forceUpdate();
this.setupTimer();
}, this.interval(closedAt));
}
}
render() {
const {isClosed, closedAt, onClose, onOpen} = this.props;
return (
<div>
<h3>{!isClosed ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}</h3>
{(!isClosed && closedAt) ? <p>{t('configure.comment_stream_will_close')} {timeago(new Date(closedAt))}.</p> : ''}
<div className={cn('close-comments-intro-wrapper', styles.wrapper)}>
<p>
{!isClosed ? t('configure.open_stream_configuration') : t('configure.close_stream_configuration')}
</p>
<Button className={styles.button} onClick={!isClosed ? onClose : onOpen}>
{!isClosed ? t('configure.close_stream') : t('configure.open_stream')}
</Button>
</div>
</div>
);
}
}
AssetStatusInfo.propTypes = {
isClosed: PropTypes.bool.isRequired,
closedAt: PropTypes.string,
onClose: PropTypes.func.isRequired,
onOpen: PropTypes.func.isRequired,
};
export default AssetStatusInfo;
@@ -0,0 +1,30 @@
import React from 'react';
import AssetStatusInfo from '../containers/AssetStatusInfo';
import Settings from '../containers/Settings';
import PropTypes from 'prop-types';
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}
/>
<hr />
<AssetStatusInfo
asset={this.props.asset}
/>
</div>
);
}
}
Configure.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
asset: PropTypes.object.isRequired,
};
export default Configure;
@@ -0,0 +1,59 @@
.root {
}
.iconList {
display: flex;
padding: 0;
margin: 10px 0;
}
.item {
list-style-type: none;
}
.button {
composes: buttonReset from "coral-framework/styles/reset.css";
display: flex;
align-items: center;
justify-content: center;
text-align: center;
background: #F0F0F0;
width: 48px;
height: 48px;
font-size: 24px;
text-align: center;
line-height: 48px;
color: #252525;
border-radius: 3px;
overflow: hidden;
margin-right: 10px;
position: relative;
border: solid 2px #F0F0F0;
outline: 0;
&:hover {
cursor: pointer;
}
}
.button:focus {
border: solid 2px #00c96B;
}
.buttonActive {
border: solid 2px #00796B;
}
.defaultIcon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.questionBox {
margin: 10px 0;
}
@@ -0,0 +1,93 @@
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';
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, questionBoxContent, onContentChange, onIconChange} = this.props;
const {loading, MarkdownEditor} = this.state;
if (loading) {
return <Spinner/>;
}
return (
<div className={styles.root}>
<h4>Include an Icon</h4>
<ul className={styles.iconList}>
{icons.map((item) => {
const name = typeof item === 'object' ? Object.keys(item)[0] : item;
const icon = typeof item === 'object' ? item[name] : item;
return (
<li
className={styles.item}
key={name}
>
<button
className={cn(
styles.button,
{[styles.buttonActive]: questionBoxIcon === name},
)}
onClick={() => onIconChange(name)}
>
{typeof icon === 'string'
? <Icon name={icon} />
: icon
}
</button>
</li>
);
})}
</ul>
<QuestionBox
className={styles.questionBox}
icon={questionBoxIcon}
content={questionBoxContent}
/>
<MarkdownEditor
value={questionBoxContent}
onChange={onContentChange}
/>
</div>
);
}
}
export default QuestionBoxBuilder;
@@ -7,9 +7,8 @@
margin: 0 10px;
}
.wrapper ul {
list-style: none;
padding: 0;
.description {
max-width: 380px;
}
.checkbox {
@@ -17,19 +16,14 @@
margin: 12px 12px 12px 0;
}
.wrapper h4 {
font-size: 14px;
margin-bottom: 5px;
}
.wrapper p {
max-width: 380px;
.list {
margin-top: 26px;
}
.wrapper {
margin-bottom: 20px;
}
.hidden {
display: none;
}
.questionBoxContainer {
margin-top: 24px;
}
@@ -0,0 +1,102 @@
import React from 'react';
import {Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
import styles from './Settings.css';
import Configuration from 'coral-framework/components/StreamConfiguration';
import QuestionBoxBuilder from './QuestionBoxBuilder';
import Slot from 'coral-framework/components/Slot';
class Settings extends React.Component {
render() {
const {
settings: {
moderation,
premodLinksEnable,
questionBoxEnable,
questionBoxContent,
questionBoxIcon,
},
onToggleModeration,
onTogglePremodLinks,
onToggleQuestionBox,
onQuestionBoxIconChange,
onQuestionBoxContentChange,
canSave,
onApply,
slotProps,
queryData,
} = this.props;
return (
<div className={styles.wrapper}>
<div className={styles.container}>
<h3>{t('configure.title')}</h3>
<Button
type="submit"
className={cn(styles.apply, 'talk-embed-stream-configuration-submit-button')}
checked={canSave}
cStyle={canSave ? 'green' : 'darkGrey'}
onClick={onApply}
disabled={!canSave}
>
{t('configure.apply')}
</Button>
<p className={styles.description}>{t('configure.description')}</p>
</div>
<div className={styles.list}>
<Configuration
checked={moderation === 'PRE'}
title={t('configure.enable_premod')}
description={t('configure.enable_premod_description')}
onCheckbox={onToggleModeration}
/>
<Configuration
checked={premodLinksEnable}
title={t('configure.enable_premod_links')}
description={t('configure.enable_premod_description')}
onCheckbox={onTogglePremodLinks}
/>
<Configuration
checked={questionBoxEnable}
title={t('configure.enable_questionbox')}
description={t('configure.enable_questionbox_description')}
onCheckbox={onToggleQuestionBox}
>
{
questionBoxEnable &&
<div className={styles.questionBoxContainer}>
<QuestionBoxBuilder
questionBoxIcon={questionBoxIcon}
questionBoxContent={questionBoxContent}
onIconChange={onQuestionBoxIconChange}
onContentChange={onQuestionBoxContentChange}
/>
</div>
}
</Configuration>
<Slot
fill="streamSettings"
queryData={queryData}
{...slotProps}
/>
</div>
</div>
);
}
}
Settings.propTypes = {
queryData: PropTypes.object.isRequired,
slotProps: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
onToggleModeration: PropTypes.func.isRequired,
onTogglePremodLinks: PropTypes.func.isRequired,
onToggleQuestionBox: PropTypes.func.isRequired,
onQuestionBoxContentChange: PropTypes.func.isRequired,
onQuestionBoxIconChange: PropTypes.func.isRequired,
onApply: PropTypes.func.isRequired,
};
export default Settings;
@@ -0,0 +1,44 @@
import React from 'react';
import {gql, compose} from 'react-apollo';
import {withFragments} from 'coral-framework/hocs';
import AssetStatusInfo from '../components/AssetStatusInfo';
import PropTypes from 'prop-types';
import {withUpdateAssetStatus} from 'coral-framework/graphql/mutations';
class AssetStatusInfoContainer extends React.Component {
openAsset = () => this.props.updateAssetStatus(this.props.asset.id, {closedAt: null});
closeAsset = () => this.props.updateAssetStatus(this.props.asset.id, {closedAt: new Date().toISOString()});
render() {
return <AssetStatusInfo
settings={this.props.asset.settings}
isClosed={this.props.asset.isClosed}
closedAt={this.props.asset.closedAt}
onOpen={this.openAsset}
onClose={this.closeAsset}
/>;
}
}
AssetStatusInfoContainer.propTypes = {
asset: PropTypes.object.isRequired,
updateAssetStatus: PropTypes.func.isRequired,
};
const withAssetStatusInfoFragments = withFragments({
asset: gql`
fragment CoralEmbedStream_AssetStatusInfo_asset on Asset {
id
closedAt
isClosed
}
`,
});
const enhance = compose(
withAssetStatusInfoFragments,
withUpdateAssetStatus,
);
export default enhance(AssetStatusInfoContainer);
@@ -0,0 +1,50 @@
import React from 'react';
import {gql, compose} from 'react-apollo';
import {withFragments} from 'coral-framework/hocs';
import Configure from '../components/Configure';
import AssetStatusInfo from './AssetStatusInfo';
import Settings from './Settings';
import PropTypes from 'prop-types';
import {getDefinitionName} from 'coral-framework/utils';
class ConfigureContainer extends React.Component {
render() {
return <Configure
data={this.props.data}
root={this.props.root}
asset={this.props.asset}
/>;
}
}
ConfigureContainer.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
asset: PropTypes.object.isRequired,
};
const withConfigureFragments = withFragments({
root: gql`
fragment CoralEmbedStream_Configure_root on RootQuery {
__typename
...${getDefinitionName(Settings.fragments.root)}
}
${Settings.fragments.root}
`,
asset: gql`
fragment CoralEmbedStream_Configure_asset on Asset {
__typename
...${getDefinitionName(AssetStatusInfo.fragments.asset)}
...${getDefinitionName(Settings.fragments.asset)}
}
${AssetStatusInfo.fragments.asset}
${Settings.fragments.asset}
`,
});
const enhance = compose(
withConfigureFragments,
);
export default enhance(ConfigureContainer);
@@ -0,0 +1,129 @@
import React from 'react';
import {gql, compose} from 'react-apollo';
import {withFragments, withMergedSettings} from 'coral-framework/hocs';
import {getErrorMessages, getSlotFragmentSpreads} from 'coral-framework/utils';
import Settings from '../components/Settings.js';
import PropTypes from 'prop-types';
import {withUpdateAssetSettings} from 'coral-framework/graphql/mutations';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {notify} from 'coral-framework/actions/notification';
import {clearPending, updatePending} from '../../../actions/configure';
const slots = [
'streamSettings',
];
class SettingsContainer extends React.Component {
toggleModeration = () => {
const updater = {moderation: {$set: this.props.mergedSettings.moderation === 'PRE' ? 'POST' : 'PRE'}};
this.props.updatePending({updater});
};
togglePremodLinks = () => {
const updater = {premodLinksEnable: {$set: !this.props.mergedSettings.premodLinksEnable}};
this.props.updatePending({updater});
};
toggleQuestionBox = () => {
const updater = {questionBoxEnable: {$set: !this.props.mergedSettings.questionBoxEnable}};
this.props.updatePending({updater});
};
setQuestionBoxIcon = (icon) => {
const updater = {questionBoxIcon: {$set: icon}};
this.props.updatePending({updater});
};
setQuestionBoxContent = (content) => {
const updater = {questionBoxContent: {$set: content}};
this.props.updatePending({updater});
};
savePending = async () => {
try {
await this.props.updateAssetSettings(this.props.asset.id, this.props.pending);
this.props.clearPending();
}
catch(err) {
this.props.notify('error', getErrorMessages(err));
}
};
render() {
const {mergedSettings, canSave, data, root, asset, errors, updatePending} = this.props;
return <Settings
settings={mergedSettings}
queryData={{root, asset, settings: mergedSettings}}
slotProps={{data, updatePending, errors}}
savePending={this.savePending}
onToggleModeration={this.toggleModeration}
onTogglePremodLinks={this.togglePremodLinks}
onToggleQuestionBox={this.toggleQuestionBox}
onQuestionBoxIconChange={this.setQuestionBoxIcon}
onQuestionBoxContentChange={this.setQuestionBoxContent}
onApply={this.savePending}
canSave={canSave}
/>;
}
}
SettingsContainer.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
asset: PropTypes.object.isRequired,
pending: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
mergedSettings: PropTypes.object.isRequired,
updateAssetSettings: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
updatePending: PropTypes.func.isRequired,
canSave: PropTypes.bool.isRequired,
};
const withSettingsFragments = withFragments({
root: gql`
fragment CoralEmbedStream_Settings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
asset: gql`
fragment CoralEmbedStream_Settings_asset on Asset {
id
settings {
moderation
premodLinksEnable
questionBoxEnable
questionBoxIcon
questionBoxContent
${getSlotFragmentSpreads(slots, 'settings')}
}
${getSlotFragmentSpreads(slots, 'asset')}
}
`,
});
const mapStateToProps = (state) => ({
pending: state.configure.pending,
canSave: state.configure.canSave,
errors: state.configure.errors,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
notify,
clearPending,
updatePending,
}, dispatch);
const enhance = compose(
withSettingsFragments,
withUpdateAssetSettings,
connect(mapStateToProps, mapDispatchToProps),
withMergedSettings('asset.settings', 'pending', 'mergedSettings'),
);
export default enhance(SettingsContainer);
@@ -14,7 +14,7 @@ const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// comments to show. The spare cursor functions as a backup in case one
// of the comments gets deleted.
function resetCursors(state, props) {
const comments = props.root.asset.comments;
const comments = props.asset.comments;
if (comments && comments.nodes.length) {
const idCursors = [comments.nodes[0].id];
if (comments.nodes[1]) {
@@ -30,7 +30,7 @@ function resetCursors(state, props) {
// using the help of the backup cursor.
function invalidateCursor(invalidated, state, props) {
const alt = invalidated === 1 ? 0 : 1;
const comments = props.root.asset.comments;
const comments = props.asset.comments;
const idCursors = [];
if (state.idCursors[alt]) {
idCursors.push(state.idCursors[alt]);
@@ -9,13 +9,13 @@ import {can} from 'coral-framework/services/perms';
import {TransitionGroup} from 'react-transition-group';
import cn from 'classnames';
import styles from './Comment.css';
import {THREADING_LEVEL} from '../constants/stream';
import {THREADING_LEVEL} from '../../../constants/stream';
import merge from 'lodash/merge';
import mapValues from 'lodash/mapValues';
import LoadMore from './LoadMore';
import {getEditableUntilDate} from './util';
import {findCommentWithId} from '../graphql/utils';
import {findCommentWithId} from '../../../graphql/utils';
import CommentContent from 'coral-framework/components/CommentContent';
import Slot from 'coral-framework/components/Slot';
import CommentTombstone from './CommentTombstone';
@@ -2,9 +2,22 @@
margin-top: 6px;
}
.viewAllButton {
.viewAllButtonContainer {
position: absolute;
right: 0px;
width: 100%;
display: flex;
justify-content: center;
margin-top: -11px;
z-index: 10;
}
.viewAllButton {
composes: buttonReset from "coral-framework/styles/reset.css";
background-color: #4D8FCC;
color: white;
padding: 4px 8px;
border-radius: 2px;
}
.tabPanel {
@@ -26,4 +39,4 @@
margin-top: 28px;
padding-bottom: 50px;
min-height: 600px;
}
}
@@ -11,15 +11,14 @@ import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
import t, {timeago} from 'coral-framework/services/i18n';
import CommentBox from 'talk-plugin-commentbox/CommentBox';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import QuestionBox from '../../../components/QuestionBox';
import {isCommentActive} from 'coral-framework/utils';
import {Button, Tab, TabCount, TabPane} from 'coral-ui';
import {Tab, TabCount, TabPane, Button} from 'coral-ui';
import cn from 'classnames';
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
import {getTopLevelParent, attachCommentToParent} from '../../../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
import StreamTabPanel from '../containers/StreamTabPanel';
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
import styles from './Stream.css';
@@ -47,7 +46,8 @@ class Stream extends React.Component {
activeReplyBox,
setActiveReplyBox,
commentClassNames,
root: {asset, asset: {comment}},
asset,
asset: {comment},
postComment,
notify,
editComment,
@@ -58,6 +58,7 @@ class Stream extends React.Component {
loadNewReplies,
auth: {user},
emit,
viewAllComments,
} = this.props;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
@@ -76,6 +77,14 @@ class Stream extends React.Component {
return (
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
<div className={cn('talk-stream-show-all-comments-button-container', styles.viewAllButtonContainer)}>
<button
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
onClick={viewAllComments}
>
{t('framework.show_all_comments')}
</button>
</div>
<Comment
data={data}
root={root}
@@ -106,14 +115,15 @@ class Stream extends React.Component {
);
}
renderTabPanel() {
renderExtendableTabPanel() {
const {
data,
root,
activeReplyBox,
setActiveReplyBox,
commentClassNames,
root: {asset, asset: {comments, totalCommentCount}},
asset,
asset: {comments, totalCommentCount},
postComment,
notify,
editComment,
@@ -135,7 +145,7 @@ class Stream extends React.Component {
const slotProps = {data};
const slotQueryData = {root, asset};
// `key` of `StreamTabPanel` depends on sorting so that we always reset
// `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)}>
@@ -148,26 +158,28 @@ class Stream extends React.Component {
{...slotProps}
/>
</div>
<StreamTabPanel
<ExtendableTabPanel
key={`${sortBy}_${sortOrder}`}
activeTab={activeStreamTab}
setActiveTab={setActiveStreamTab}
fallbackTab={'all'}
tabSlot={'streamTabs'}
tabPaneSlot={'streamTabPanes'}
fallbackTab='all'
tabSlot='streamTabs'
tabSlotPrepend='streamTabsPrepend'
tabPaneSlot='streamTabPanes'
slotProps={slotProps}
queryData={slotQueryData}
loading={loading}
appendTabs={
tabs={
<Tab tabId={'all'} key='all'>
{t('stream.all_comments')} <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
}
appendTabPanes={
<TabPane tabId={'all'} key='all'>
tabPanes={
<TabPane tabId='all' key='all'>
<AllCommentsPane
data={data}
root={root}
asset={asset}
comments={comments}
commentClassNames={commentClassNames}
setActiveReplyBox={setActiveReplyBox}
@@ -175,7 +187,6 @@ class Stream extends React.Component {
notify={notify}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
@@ -218,11 +229,16 @@ class Stream extends React.Component {
data,
root,
appendItemArray,
root: {asset, asset: {comment: highlightedComment}},
asset,
asset: {
comment: highlightedComment,
settings: {
questionBoxEnable,
}
},
postComment,
notify,
updateItem,
viewAllComments,
auth: {loggedIn, user},
editName,
} = this.props;
@@ -230,14 +246,13 @@ class Stream extends React.Component {
const open = !asset.isClosed;
const banned = user && user.status === 'BANNED';
const pending = user && user.status === 'PENDING';
const temporarilySuspended =
user &&
user.suspension.until &&
new Date(user.suspension.until) > new Date();
const showCommentBox = loggedIn && ((!banned && !pending & !temporarilySuspended && !highlightedComment) || keepCommentBox);
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
const slotProps = {data};
const slotQueryData = {root, asset};
@@ -247,33 +262,29 @@ class Stream extends React.Component {
return (
<div id="stream" className={styles.root}>
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
<Button
cStyle="darkGrey"
onClick={this.launchDeathStar}
>
Launch Death Star
</Button>
{highlightedComment &&
<Button
cStyle="darkGrey"
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
onClick={viewAllComments}
>
{t('framework.show_all_comments')}
</Button>}
{open
? <div id="commentBox">
<InfoBox
content={asset.settings.infoBoxContent}
enable={asset.settings.infoBoxEnable}
/>
<QuestionBox
content={asset.settings.questionBoxContent}
enable={asset.settings.questionBoxEnable}
icon={asset.settings.questionBoxIcon}
/>
{questionBoxEnable && (
<QuestionBox
content={asset.settings.questionBoxContent}
icon={asset.settings.questionBoxIcon}>
<Slot
fill="streamQuestionArea"
queryData={slotQueryData}
{...slotProps}
/>
</QuestionBox>
)}
{!banned &&
temporarilySuspended &&
<RestrictedMessageBox>
@@ -320,7 +331,7 @@ class Stream extends React.Component {
{highlightedComment
? this.renderHighlightedComment()
: this.renderTabPanel()
: this.renderExtendableTabPanel()
}
</div>
);
@@ -2,7 +2,7 @@ import React, {Component} from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import styles from './SuspendAccount.css';
import styles from './SuspendedAccount.css';
import {Button} from 'coral-ui';
import validate from 'coral-framework/helpers/validate';
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
@@ -4,9 +4,9 @@ import Comment from '../components/Comment';
import {withFragments} from 'coral-framework/hocs';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {THREADING_LEVEL} from '../constants/stream';
import {THREADING_LEVEL} from '../../../constants/stream';
import hoistStatics from 'recompose/hoistStatics';
import {nest} from '../graphql/utils';
import {nest} from '../../../graphql/utils';
const slots = [
'streamQuestionArea',
@@ -2,7 +2,7 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream';
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../../../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree,
withDeleteAction, withEditComment
@@ -10,7 +10,7 @@ import {
import * as authActions from 'coral-embed-stream/src/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../../../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments, withEmit} from 'coral-framework/hocs';
@@ -23,7 +23,7 @@ import {
removeCommentFromEmbedQuery,
insertFetchedCommentsIntoEmbedQuery,
nest,
} from '../graphql/utils';
} from '../../../graphql/utils';
const {showSignInDialog, editName} = authActions;
const {notify} = notificationActions;
@@ -36,7 +36,7 @@ class StreamContainer extends React.Component {
this.commentsEditedSubscription = this.props.data.subscribeToMore({
document: COMMENTS_EDITED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
assetId: this.props.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
@@ -62,7 +62,7 @@ class StreamContainer extends React.Component {
this.commentsAddedSubscription = this.props.data.subscribeToMore({
document: COMMENTS_ADDED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
assetId: this.props.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
@@ -116,7 +116,7 @@ class StreamContainer extends React.Component {
limit: parent_id ? 999999 : ADDTL_COMMENTS_ON_LOAD_MORE,
cursor: comment.replies.endCursor,
parent_id,
asset_id: this.props.root.asset.id,
asset_id: this.props.asset.id,
sortOrder: 'ASC',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
@@ -131,9 +131,9 @@ class StreamContainer extends React.Component {
query: LOAD_MORE_QUERY,
variables: {
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
cursor: this.props.root.asset.comments.endCursor,
cursor: this.props.asset.comments.endCursor,
parent_id: null,
asset_id: this.props.root.asset.id,
asset_id: this.props.asset.id,
sortOrder: this.props.data.variables.sortOrder,
sortBy: this.props.data.variables.sortBy,
excludeIgnored: this.props.data.variables.excludeIgnored,
@@ -177,9 +177,9 @@ class StreamContainer extends React.Component {
}
render() {
if (!this.props.root.asset
|| this.props.root.asset.comment === undefined
&& !this.props.root.asset.comments
if (!this.props.asset
|| this.props.asset.comment === undefined
&& !this.props.asset.comments
) {
return <Spinner />;
}
@@ -271,6 +271,7 @@ const LOAD_MORE_QUERY = gql`
const slots = [
'streamTabs',
'streamTabsPrepend',
'streamTabPanes',
'streamFilter',
];
@@ -278,49 +279,6 @@ const slots = [
const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
asset(id: $assetId, url: $assetUrl) {
comment(id: $commentId) @include(if: $hasComment) {
...CoralEmbedStream_Stream_comment
${nest(`
parent {
...CoralEmbedStream_Stream_comment
...nest
}
`, THREADING_LEVEL)}
}
id
title
url
closedAt
isClosed
created_at
settings {
moderation
infoBoxEnable
infoBoxContent
premodLinksEnable
questionBoxEnable
questionBoxContent
questionBoxIcon
closedTimeout
closedMessage
charCountEnable
charCount
requireEmailConfirmation
}
commentCount @skip(if: $hasComment)
totalCommentCount @skip(if: $hasComment)
comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) {
nodes {
...CoralEmbedStream_Stream_comment
}
hasNextPage
startCursor
endCursor
}
${getSlotFragmentSpreads(slots, 'asset')}
...${getDefinitionName(Comment.fragments.asset)}
}
me {
status
ignoredUsers {
@@ -333,8 +291,52 @@ const fragments = {
${getSlotFragmentSpreads(slots, 'root')}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.asset}
${Comment.fragments.root}
`,
asset: gql`
fragment CoralEmbedStream_Stream_asset on Asset {
comment(id: $commentId) @include(if: $hasComment) {
...CoralEmbedStream_Stream_comment
${nest(`
parent {
...CoralEmbedStream_Stream_comment
...nest
}
`, THREADING_LEVEL)}
}
id
title
url
isClosed
created_at
settings {
moderation
infoBoxEnable
infoBoxContent
premodLinksEnable
questionBoxEnable
questionBoxContent
questionBoxIcon
closedTimeout
closedMessage
charCountEnable
charCount
requireEmailConfirmation
}
commentCount @skip(if: $hasComment)
totalCommentCount @skip(if: $hasComment)
comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) {
nodes {
...CoralEmbedStream_Stream_comment
}
hasNextPage
startCursor
endCursor
}
${getSlotFragmentSpreads(slots, 'asset')}
...${getDefinitionName(Comment.fragments.asset)}
}
${Comment.fragments.asset}
${commentFragment}
`,
};
@@ -275,22 +275,6 @@ button.comment__action-button[disabled],
/* Close comments */
.close-comments-intro-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
}
.close-comments-intro-wrapper button {
width: 300px;
margin-left: 20px;
}
.close-comments-intro-wrapper button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.close-comments-message {
box-sizing: border-box;
width: 100%;
@@ -107,6 +107,11 @@ export default class MarkdownEditor extends Component {
...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);
}
@@ -0,0 +1,33 @@
.root {
position: relative;
margin: 12px 12px 24px 0;
}
.action {
display: inline-block;
position: absolute;
top: 0;
left: 0;
padding-left: 4px;
}
.title {
display: block;
font-size: 14px;
margin-bottom: 5px;
font-weight: bold;
cursor: pointer;
padding-right: 50px;
}
.description{
padding-right: 50px;
}
.content {
display: inline-block;
box-sizing: border-box;
padding-left: 50px;
}
@@ -0,0 +1,48 @@
import React from 'react';
import Checkbox from 'coral-ui/components/Checkbox';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './StreamConfiguration.css';
import uuid from 'uuid/v4';
class Configuration extends React.Component {
id = uuid();
render() {
const {title, description, children, className, onCheckbox, checked, ...rest} = this.props;
return (
<div {...rest} className={cn(styles.root, className)}>
{checked !== undefined &&
<div className={styles.action}>
<Checkbox
id={this.id}
className={styles.checkbox}
onChange={onCheckbox}
checked={checked} />
</div>
}
<div className={cn(styles.wrapper, {
[styles.content]: checked !== undefined,
})}>
<label htmlFor={this.id} className={styles.title}>{title}</label>
<div className={styles.description}>{description}</div>
<div>
{children}
</div>
</div>
</div>
);
}
}
Configuration.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
className: PropTypes.string,
onCheckbox: PropTypes.func,
checked: PropTypes.bool,
children: PropTypes.node,
};
export default Configuration;
@@ -17,6 +17,8 @@ export default {
'IgnoreUserResponse',
'StopIgnoringUserResponse',
'UpdateSettingsResponse',
'UpdateAssetSettingsResponse',
'UpdateAssetStatusResponse',
)
};
+68 -1
View File
@@ -88,7 +88,7 @@ export const withRemoveTag = withMutation(
asset_id: assetId,
item_type: itemType,
},
optimisticResponse: {
o3timisticResponse: {
removeTag: {
__typename: 'ModifyTagResponse',
errors: null,
@@ -363,3 +363,70 @@ export const withUpdateSettings = withMutation(
});
}}),
});
export const withUpdateAssetSettings = withMutation(
gql`
mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) {
updateAssetSettings(id: $id, input: $input) {
...UpdateAssetSettingsResponse
}
}
`, {
props: ({mutate}) => ({
updateAssetSettings: (id, input) => {
return mutate({
variables: {
id,
input,
},
optimisticResponse: {
updateAssetSettings: {
__typename: 'UpdateAssetSettingsResponse',
errors: null,
}
},
});
}}),
});
export const withUpdateAssetStatus = withMutation(
gql`
mutation UpdateAssetStatus($id: ID!, $input: UpdateAssetStatusInput!) {
updateAssetStatus(id: $id, input: $input) {
...UpdateAssetStatusResponse
}
}
`, {
props: ({mutate}) => ({
updateAssetStatus: (id, input) => {
return mutate({
variables: {
id,
input,
},
optimisticResponse: {
updateAssetStatus: {
__typename: 'UpdateAssetStatusResponse',
errors: null,
}
},
update: (proxy) => {
if (input.closedAt !== undefined) {
const fragment = gql`
fragment Talk_UpdateAssetStatusResponse on Asset {
closedAt
isClosed
}`;
const fragmentId = `Asset_${id}`;
const data = {
__typename: 'Asset',
closedAt: input.closedAt,
isClosed: !!input.closedAt && new Date(input.closedAt).getTime() <= new Date().getTime(),
};
proxy.writeFragment({fragment, id: fragmentId, data});
}
}
});
}}),
});
+1
View File
@@ -5,3 +5,4 @@ export {default as withCopyToClipboard} from './withCopyToClipboard';
export {default as withEmit} from './withEmit';
export {default as excludeIf} from './excludeIf';
export {default as connect} from './connect';
export {default as withMergedSettings} from './withMergedSettings';
@@ -0,0 +1,25 @@
import {mergeExcludingArrays} from 'coral-framework/utils';
import assignWith from 'lodash/assignWith';
import get from 'lodash/get';
import {withPropsOnChange} from 'recompose';
/**
* Exports a HOC that applies props at `pending` to
* props at `settings` and writes into `result` prop name.
* `Settings`, and `pending` can have a dotnotation like
* "asset.settings".
*
* Example:
* withMergedSettings('asset.settings', 'pending', 'mergedSettings')
*/
const withMergedSettings = (settings, pending, result) =>
withPropsOnChange(
(props, nextProps) =>
get(props, settings) !== get(nextProps, settings) ||
get(props, pending) !== get(nextProps, pending),
(props) => ({
[result]: assignWith({}, get(props, settings), get(props, pending), mergeExcludingArrays)
})
);
export default withMergedSettings;
+20
View File
@@ -2,6 +2,8 @@ import {gql} from 'react-apollo';
import t from 'coral-framework/services/i18n';
import union from 'lodash/union';
import {capitalize} from 'coral-framework/helpers/strings';
import assignWith from 'lodash/assignWith';
import mapValues from 'lodash/mapValues';
export * from 'coral-framework/helpers/strings';
export const getTotalActionCount = (type, comment) => {
@@ -197,3 +199,21 @@ export function getTotalReactionsCount(actionSummaries) {
.filter(({__typename}) => !NOT_REACTION_TYPES.includes(__typename))
.reduce((total, {count}) => total + count, 0);
}
// Like lodash merge but does not recurse into arrays.
export function mergeExcludingArrays(objValue, srcValue) {
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
}
return srcValue;
}
// Map nested object leaves. Array objects are considered leaves.
export function mapLeaves(o, mapper) {
return mapValues(o, (val) => {
if (typeof val === 'object' && !Array.isArray(val)) {
return mapLeaves(val, mapper);
}
return mapper(val);
});
}
+15 -26
View File
@@ -1,10 +1,9 @@
.label {
.root {
position: relative;
display: inline-block;
}
.label input {
visibility: hidden;
.input {
position: absolute;
left: 7px;
bottom: 7px;
@@ -12,6 +11,7 @@
padding: 0;
outline: none;
cursor: pointer;
pointer-events: none;
opacity: 0;
}
@@ -19,21 +19,9 @@
cursor: pointer;
}
.label input[type="checkbox"]:checked + .checkbox:before {
content: "\e834";
}
.label input[type="checkbox"] + .checkbox:before {
.checkbox:before {
content: "\e835";
color: #717171;
}
.label.type--green input[type="checkbox"] + .checkbox:before {
color: #00a291;
}
.label input[type="checkbox"] + .checkbox:before {
position: absolute;
left: 4px;
top: 0px;
width: 18px;
@@ -49,22 +37,23 @@
white-space: nowrap;
direction: ltr;
vertical-align: -6px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-feature-settings: 'liga';
font-feature-settings: 'liga';
-webkit-transition: all .2s ease;
transition: all .2s ease;
z-index: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.checkboxInfo {
display: inline-block;
max-width: 360px;
margin-left: 50px;
.checkboxChecked:before {
content: "\e834";
color: #00a291;
}
.checkboxInfo h4 {
margin: 0 0 5px;
.input:focus + .checkbox:before {
color: #00a291;
}
.input:focus + .checkboxChecked:before {
color: #00e291;
}
+23 -11
View File
@@ -1,16 +1,28 @@
import React from 'react';
import styles from './Checkbox.css';
import cn from 'classnames';
import PropTypes from 'prop-types';
export default ({name, cStyle = 'base', onChange, label, className, info, ...attrs}) => (
<label className={`${styles.label} ${styles[`type--${cStyle}`]} ${className}`} htmlFor={name}>
<input type="checkbox" id={name} name={name} onChange={onChange} {...attrs} />
<span className={styles.checkbox}></span>
{label && <span>{label}</span>}
{info && (
<div className={styles.checkboxInfo}>
<h4>{info.title}</h4>
<span>{info.description}</span>
</div>
)}
const Checkbox = ({onChange, checked, className, ...rest}) => (
<label className={cn(styles.root, className)}>
<input
type="checkbox"
className={cn(styles.input, {[styles.inputChecked]: checked})}
onChange={onChange}
checked={checked}
{...rest}
/>
<span
className={cn(styles.checkbox, {[styles.checkboxChecked]: checked})}
aria-hidden='true'
></span>
</label>
);
Checkbox.propTypes = {
className: PropTypes.string,
onChange: PropTypes.func,
checked: PropTypes.bool,
};
export default Checkbox;
+83
View File
@@ -0,0 +1,83 @@
// Errors.
const errors = require('../errors');
// Models.
const Action = require('../models/action');
const Asset = require('../models/asset');
const Comment = require('../models/comment');
const Setting = require('../models/setting');
const User = require('../models/user');
// Services.
const Actions = require('../services/actions');
const Assets = require('../services/assets');
const Cache = require('../services/cache');
const Comments = require('../services/comments');
const DomainList = require('../services/domainlist');
const I18n = require('../services/i18n');
const Jwt = require('../services/jwt');
const Karma = require('../services/karma');
const Kue = require('../services/kue');
const Limit = require('../services/limit');
const Locals = require('../services/locals');
const Mailer = require('../services/mailer');
const Metadata = require('../services/metadata');
const Migration = require('../services/migration');
const Mongoose = require('../services/mongoose');
const Passport = require('../services/passport');
const Plugins = require('../services/plugins');
const Pubsub = require('../services/pubsub');
const Redis = require('../services/redis');
const Regex = require('../services/regex');
const Scraper = require('../services/scraper');
const Settings = require('../services/settings');
const Setup = require('../services/setup');
const Subscriptions = require('../services/subscriptions');
const Tags = require('../services/tags');
const Tokens = require('../services/tokens');
const Users = require('../services/users');
const Wordlist = require('../services/wordlist');
// Connector.
const connectors = {
errors,
models: {
Action,
Asset,
Comment,
Setting,
User,
},
services: {
Actions,
Assets,
Cache,
Comments,
DomainList,
I18n,
Jwt,
Karma,
Kue,
Limit,
Locals,
Mailer,
Metadata,
Migration,
Mongoose,
Passport,
Plugins,
Pubsub,
Redis,
Regex,
Scraper,
Settings,
Setup,
Subscriptions,
Tags,
Tokens,
Users,
Wordlist,
},
};
module.exports = connectors;
+4
View File
@@ -2,6 +2,7 @@ const loaders = require('./loaders');
const mutators = require('./mutators');
const uuid = require('uuid');
const merge = require('lodash/merge');
const connectors = require('./connectors');
const plugins = require('../services/plugins');
const pubsub = require('../services/pubsub');
@@ -51,6 +52,9 @@ class Context {
this.user = user;
}
// Attach the connectors.
this.connectors = connectors;
// Create the loaders.
this.loaders = loaders(this);
+1 -1
View File
@@ -45,7 +45,7 @@ const AssetSchema = new Schema({
// always after running `rectifySettings` against it.
settings: {
type: Schema.Types.Mixed,
default: null
default: {},
},
// Tags are added by the self or by administrators.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "3.8.0",
"version": "3.8.1",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -7,3 +7,4 @@ export {default as CommentTimestamp} from 'coral-framework/components/CommentTim
export {default as CommentDetail} from 'coral-framework/components/CommentDetail';
export {default as CommentContent} from 'coral-framework/components/CommentContent';
export {default as ConfigureCard} from 'coral-framework/components/ConfigureCard';
export {default as StreamConfiguration} from 'coral-framework/components/StreamConfiguration';
@@ -1,9 +0,0 @@
import {SHOW_TOOLTIP, HIDE_TOOLTIP} from './constants';
export const showTooltip = () => ({
type: SHOW_TOOLTIP
});
export const hideTooltip = () => ({
type: HIDE_TOOLTIP
});
@@ -3,9 +3,9 @@ import styles from './InfoIcon.css';
import cn from 'classnames';
import {Icon} from 'plugin-api/beta/client/components/ui';
export default ({tooltip}) => (
<Icon
export default ({hover}) => (
<Icon
name="info_outline"
className={cn(styles.infoIcon, {[styles.on]: tooltip})}
className={cn(styles.infoIcon, {[styles.on]: hover})}
/>
);
@@ -3,16 +3,23 @@ import {TabCount} from 'plugin-api/beta/client/components/ui';
import InfoIcon from './InfoIcon';
import {t} from 'plugin-api/beta/client/services';
import Tooltip from './Tooltip';
import PropTypes from 'prop-types';
export default ({active, asset: {featuredCommentsCount}, tooltip, ...props}) => {
const Tab = ({active, hover, featuredCommentsCount}) => {
return (
<span
onMouseEnter={props.showTooltip}
onMouseLeave={props.hideTooltip} >
<span>
{t('talk-plugin-featured-comments.featured')}
<TabCount active={active} sub>{featuredCommentsCount}</TabCount>
<InfoIcon tooltip={tooltip} />
{tooltip && <Tooltip />}
<InfoIcon hover={hover} />
{hover && <Tooltip />}
</span>
);
};
Tab.propTypes = {
active: PropTypes.bool,
hover: PropTypes.bool,
featuredCommentsCount: PropTypes.number.isRequired,
};
export default Tab;
@@ -1,4 +0,0 @@
const prefix = 'TALK_FEATURED_COMMENTS';
export const SHOW_TOOLTIP = `${prefix}_SHOW_TOOLTIP`;
export const HIDE_TOOLTIP = `${prefix}_HIDE_TOOLTIP`;
@@ -1,19 +1,9 @@
import Tab from '../components/Tab';
import {bindActionCreators} from 'redux';
import {showTooltip, hideTooltip} from '../actions';
import {withFragments, excludeIf} from 'plugin-api/beta/client/hocs';
import {compose, gql} from 'react-apollo';
import {withFragments, excludeIf, connect} from 'plugin-api/beta/client/hocs';
const mapStateToProps = ({talkPluginFeaturedComments: state}) => state;
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showTooltip,
hideTooltip,
}, dispatch);
import {withProps} from 'recompose';
const enhance = compose(
connect(mapStateToProps, mapDispatchToProps),
withFragments({
asset: gql`
fragment TalkFeaturedComments_Tab_asset on Asset {
@@ -21,6 +11,7 @@ const enhance = compose(
}`,
}),
excludeIf((props) => props.asset.featuredCommentsCount === 0),
withProps((props) => ({featuredCommentsCount: props.asset.featuredCommentsCount})),
);
export default enhance(Tab);
@@ -3,7 +3,6 @@ import Tag from './containers/Tag';
import TabPane from './containers/TabPane';
import translations from './translations.yml';
import update from 'immutability-helper';
import reducer from './reducer';
import ModTag from './containers/ModTag';
import ModActionButton from './containers/ModActionButton';
import ModSubscription from './containers/ModSubscription';
@@ -13,10 +12,9 @@ import {findCommentInEmbedQuery} from 'coral-embed-stream/src/graphql/utils';
import {prependNewNodes} from 'plugin-api/beta/client/utils';
export default {
reducer,
translations,
slots: {
streamTabs: [Tab],
streamTabsPrepend: [Tab],
streamTabPanes: [TabPane],
commentInfoBar: [Tag],
moderationActions: [ModActionButton],
@@ -57,7 +55,7 @@ export default {
}
const comment = findCommentInEmbedQuery(previous, variables.id);
if (previous.asset.comments) {
updated = update(previous, {
asset: {
@@ -101,7 +99,7 @@ export default {
updateQueries: {
CoralEmbedStream_Embed: (previous) => {
let updated = previous;
if (variables.name !== 'FEATURED') {
return;
}
@@ -1,22 +0,0 @@
import {SHOW_TOOLTIP, HIDE_TOOLTIP} from './constants';
const initialState = {
tooltip: false
};
export default function featured (state = initialState, action) {
switch (action.type) {
case SHOW_TOOLTIP:
return {
...state,
tooltip: true
};
case HIDE_TOOLTIP:
return {
...state,
tooltip: false
};
default :
return state;
}
}
+3 -2
View File
@@ -38,7 +38,7 @@ router.get('/', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, nex
const {
value = '',
field = 'created_at',
field = 'publication_date',
page = 1,
asc = 'false',
filter = 'all',
@@ -46,9 +46,10 @@ router.get('/', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, nex
} = req.query;
try {
const order = (asc === 'true') ? 1 : -1;
const queryOpts = {
sort: {[field]: (asc === 'true') ? 1 : -1},
sort: {[field]: order, 'created_at': order},
skip: (page - 1) * limit,
limit
};
+33 -11
View File
@@ -4,6 +4,8 @@ const SettingsService = require('./settings');
const domainlist = require('./domainlist');
const errors = require('../errors');
const merge = require('lodash/merge');
const isEmpty = require('lodash/isEmpty');
const {dotize} = require('./utils');
module.exports = class AssetsService {
@@ -39,7 +41,7 @@ module.exports = class AssetsService {
]);
// If the asset exists and has settings then return the merged object.
if (asset && asset.settings) {
if (asset && asset.settings && !isEmpty(asset.settings)) {
settings = merge({}, globalSettings, asset.settings);
} else {
settings = globalSettings;
@@ -94,18 +96,38 @@ module.exports = class AssetsService {
/**
* Updates the settings for the asset.
* @param {[type]} id [description]
* @param {[type]} settings [description]
* @return {[type]} [description]
* @param {String} id id of asset
* @param {Object} settings new settings values
* @return {Promise}
*/
static overrideSettings(id, settings) {
return AssetModel.findOneAndUpdate({id}, {
$set: {
settings
static async overrideSettings(id, settings) {
try {
const result = await AssetModel.findOneAndUpdate({id}, {
// The effect of dotize is that only the provided setting values are overwritten
// and does not replace the whole object.
$set: dotize({settings})
}, {
new: true
});
return result;
} catch (e) {
// Legacy data models contains `settings=null` as a default which cannot be traversed.
// New data models uses `settings={}`.
if (e.code === 16837) {
// Overwrite it fully in this case.
const result = await AssetModel.findOneAndUpdate({id}, {
$set: {settings}
}, {
new: true
});
return result;
} else {
throw e;
}
}, {
new: true
});
}
}
/**
+15
View File
@@ -21,6 +21,9 @@ const getQueue = () => {
}
});
// Watch for stuck jobs to manage.
queue.watchStuckJobs(1000);
return queue;
};
@@ -47,6 +50,7 @@ class Task {
.attempts(this.attempts)
.delay(this.delay)
.backoff({type: 'exponential'})
.removeOnComplete(true)
.save((err) => {
if (err) {
return reject(err);
@@ -66,6 +70,15 @@ class Task {
return getQueue().process(this.name, callback);
}
/**
* Connect to redis now by getting the queue.
*/
static connect() {
// Force setup the redis connection for kue.
getQueue();
}
/**
* Shutdown running jobs.
*/
@@ -145,3 +158,5 @@ if (process.env.NODE_ENV === 'test') {
module.exports.Task = Task;
}
// Add the job reference to the exported params.
module.exports.Job = kue.Job;
+1 -26
View File
@@ -1,31 +1,6 @@
const SettingModel = require('../models/setting');
const errors = require('../errors');
function dotizeRecurse(result, object, path = '') {
for (const key in object) {
const newPath = path ? `${path}.${key}` : key;
if (typeof object[key] === 'object' && !Array.isArray(object[key])) {
dotizeRecurse(result, object[key], newPath);
continue;
}
result[newPath] = object[key];
}
}
/**
* Dotize turns a nested object into flattened object with
* dotized key notation. Arrays do not become dotized.
*
* e.g. {a: {b: 'c'}} becomes {'a.b': 'c}
*
* @param {Object} object
* @return {Object} dotized object
*/
function dotize(object) {
const result = {};
dotizeRecurse(result, object);
return result;
}
const {dotize} = require('./utils');
/**
* The selector used to uniquely identify the settings document.
+30
View File
@@ -0,0 +1,30 @@
function dotizeRecurse(result, object, path = '') {
for (const key in object) {
const newPath = path ? `${path}.${key}` : key;
if (typeof object[key] === 'object' && !Array.isArray(object[key])) {
dotizeRecurse(result, object[key], newPath);
continue;
}
result[newPath] = object[key];
}
}
/**
* Dotize turns a nested object into flattened object with
* dotized key notation. Arrays do not become dotized.
*
* e.g. {a: {b: 'c'}} becomes {'a.b': 'c}
*
* @param {Object} object
* @return {Object} dotized object
*/
function dotize(object) {
const result = {};
dotizeRecurse(result, object);
return result;
}
module.exports = {
dotize,
};
+1 -1
View File
@@ -91,7 +91,7 @@ describe('services.AssetsService', () => {
.findOrCreateByUrl('https://override.test.com/asset')
.then((asset) => {
expect(asset).to.have.property('settings');
expect(asset.settings).to.be.null;
expect(asset.settings).to.be.empty;
return AssetsService.overrideSettings(asset.id, {moderation: 'PRE'});
})
+10 -1
View File
@@ -7364,7 +7364,7 @@ react-mdl-selectfield@^0.2.0:
react-dom "^15.3.1"
react-mdl "^1.7.1"
react-mdl@^1.7.1, react-mdl@^1.7.2:
react-mdl@^1.7.1:
version "1.10.3"
resolved "https://registry.yarnpkg.com/react-mdl/-/react-mdl-1.10.3.tgz#f783e26a5eea4154a32129ab2562c09d5eeacf0d"
dependencies:
@@ -7373,6 +7373,15 @@ react-mdl@^1.7.1, react-mdl@^1.7.2:
lodash.isequal "^4.4.0"
prop-types "^15.5.0"
react-mdl@^1.7.2:
version "1.11.0"
resolved "https://registry.yarnpkg.com/react-mdl/-/react-mdl-1.11.0.tgz#7e07ee1009dd9b358b616dc400ff2ae1845a2e67"
dependencies:
clamp "^1.0.1"
classnames "^2.2.3"
lodash.isequal "^4.4.0"
prop-types "^15.5.0"
react-paginate@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/react-paginate/-/react-paginate-5.0.0.tgz#b5c12191ea81adc6d4d1b339b805e81841eaa8ea"