diff --git a/README.md b/README.md index f47fbd2f6..64fe2739c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) -A commenting platform from [The Coral Project](https://coralproject.net). Talk enters a closed beta in March 2017, but you can download the code for our alpha here. [Read more about Talk here.](https://coralproject.net/products/talk.html) +A commenting platform from [The Coral Project](https://coralproject.net). Talk enters a closed beta in March 2017, but you can download the code for our alpha here. [Read more about Talk here.](https://coralproject.net/products/talk.html) + +Third party licenses are available via the `/client/3rdpartylicenses.txt` +endpoint when the server is running with built assets. ## Contributing to Talk diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js index 9fc308d2f..ceecf16c9 100644 --- a/client/coral-admin/src/actions/install.js +++ b/client/coral-admin/src/actions/install.js @@ -20,13 +20,17 @@ const validation = (formData, dispatch, next) => { return dispatch(hasError()); } + const validKeys = Object.keys(formData) + .filter(name => name !== 'domains'); + // Required Validation - const empty = Object.keys(formData).filter(name => { + const empty = validKeys + .filter(name => { const cond = !formData[name].length; if (cond) { - // Adding Error + // Adding Error dispatch(addError(name, 'This field is required.')); } else { dispatch(addError(name, '')); @@ -40,18 +44,19 @@ const validation = (formData, dispatch, next) => { } // RegExp Validation - const validation = Object.keys(formData).filter(name => { - const cond = !validate[name](formData[name]); - if (cond) { + const validation = validKeys + .filter(name => { + const cond = !validate[name](formData[name]); + if (cond) { // Adding Error - dispatch(addError(name, errorMsj[name])); - } else { - dispatch(addError(name, '')); - } + dispatch(addError(name, errorMsj[name])); + } else { + dispatch(addError(name, '')); + } - return cond; - }); + return cond; + }); if (validation.length) { return dispatch(hasError()); @@ -71,22 +76,27 @@ export const submitSettings = () => (dispatch, getState) => { export const submitUser = () => (dispatch, getState) => { const userFormData = getState().install.toJS().data.user; validation(userFormData, dispatch, function() { - const data = getState().install.toJS().data; - dispatch(installRequest()); - coralApi('/setup', {method: 'POST', body: data}) - .then(result => { - dispatch(installSuccess(result)); - dispatch(nextStep()); - }) - .catch(error => { - console.error(error); - dispatch(installFailure(`${error.translation_key}`)); - }); + dispatch(nextStep()); }); }; +export const finishInstall = () => (dispatch, getState) => { + const data = getState().install.toJS().data; + dispatch(installRequest()); + return coralApi('/setup', {method: 'POST', body: data}) + .then(() => { + dispatch(installSuccess()); + dispatch(nextStep()); + }) + .catch(error => { + console.error(error); + dispatch(installFailure(`${error.translation_key}`)); + }); +}; + export const updateSettingsFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_SETTINGS, name, value}); export const updateUserFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_USER, name, value}); +export const updatePermittedDomains = (value) => ({type: actions.UPDATE_PERMITTED_DOMAINS_SETTINGS, value}); const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST}); const checkInstallSuccess = installed => ({type: actions.CHECK_INSTALL_SUCCESS, installed}); diff --git a/client/coral-admin/src/components/ActionButton.js b/client/coral-admin/src/components/ActionButton.js index 1d1d4d9d0..3bd96abad 100644 --- a/client/coral-admin/src/components/ActionButton.js +++ b/client/coral-admin/src/components/ActionButton.js @@ -1,21 +1,16 @@ import React from 'react'; import styles from './ModerationList.css'; -import BanUserButton from './BanUserButton'; -import {FabButton} from 'coral-ui'; +import {Button} from 'coral-ui'; import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap'; -const ActionButton = ({type = '', user, ...props}) => { - if (type === 'BAN') { - return props.showBanUserDialog(props.user, props.id)} />; - } - +const ActionButton = ({type = '', ...props}) => { return ( - + >{menuActionsMap[type].text} ); }; diff --git a/client/coral-admin/src/components/BanUserButton.css b/client/coral-admin/src/components/BanUserButton.css index 79b805c30..36243348f 100644 --- a/client/coral-admin/src/components/BanUserButton.css +++ b/client/coral-admin/src/components/BanUserButton.css @@ -1,6 +1,7 @@ .banButton { - width: 114px; - letter-spacing: 1px; + -webkit-transform: scale(.8); + transform: scale(.8); + margin: 0; i { vertical-align: middle; diff --git a/client/coral-admin/src/components/BanUserButton.js b/client/coral-admin/src/components/BanUserButton.js index 191164ca5..a54cdd322 100644 --- a/client/coral-admin/src/components/BanUserButton.js +++ b/client/coral-admin/src/components/BanUserButton.js @@ -8,7 +8,7 @@ const lang = new I18n(translations); const BanUserButton = ({user, ...props}) => (
-
@@ -65,10 +69,14 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ - checkInstall: next => dispatch(checkInstall(next)), nextStep: () => dispatch(nextStep()), - previousStep: () => dispatch(previousStep()), goToStep: step => dispatch(goToStep(step)), + previousStep: () => dispatch(previousStep()), + finishInstall: () => dispatch(finishInstall()), + checkInstall: next => dispatch(checkInstall(next)), + handleDomainsChange: value => { + dispatch(updatePermittedDomains(value)); + }, handleSettingsChange: e => { const {name, value} = e.currentTarget; dispatch(updateSettingsFormData(name, value)); diff --git a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js index ae23d0048..bcac6ef29 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js +++ b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js @@ -2,25 +2,26 @@ import React from 'react'; import styles from './style.css'; import {TextField, Button} from 'coral-ui'; +const lang = new I18n(translations); +import translations from '../../translations.json'; +import I18n from 'coral-framework/modules/i18n/i18n'; + const AddOrganizationName = props => { const {handleSettingsChange, handleSettingsSubmit, install} = props; return (
-

- Please tell us the name of your organization. This will appear in emails when - inviting new team members -

+

{lang.t('ADD_ORGANIZATION.DESCRIPTION')}

- +
diff --git a/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js b/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js index cb0f4635f..416fac195 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js +++ b/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js @@ -2,6 +2,10 @@ import React from 'react'; import styles from './style.css'; import {TextField, Button, Spinner} from 'coral-ui'; +const lang = new I18n(translations); +import translations from '../../translations.json'; +import I18n from 'coral-framework/modules/i18n/i18n'; + const InitialStep = props => { const {handleUserChange, handleUserSubmit, install} = props; return ( @@ -12,7 +16,7 @@ const InitialStep = props => { className={styles.textField} id="email" type="email" - label='Email address' + label={lang.t('CREATE.EMAIL')} onChange={handleUserChange} showErrors={install.showErrors} errorMsg={install.errors.email} @@ -23,7 +27,7 @@ const InitialStep = props => { className={styles.textField} id="username" type="text" - label='Username' + label={lang.t('CREATE.USERNAME')} onChange={handleUserChange} showErrors={install.showErrors} errorMsg={install.errors.username} @@ -33,7 +37,7 @@ const InitialStep = props => { className={styles.textField} id="password" type="password" - label='Password' + label={lang.t('CREATE.PASSWORD')} onChange={handleUserChange} showErrors={install.showErrors} errorMsg={install.errors.password} @@ -43,7 +47,7 @@ const InitialStep = props => { className={styles.textField} id="confirmPassword" type="password" - label='Confirm Password' + label={lang.t('CREATE.CONFIRM_PASSWORD')} onChange={handleUserChange} showErrors={install.showErrors} errorMsg={install.errors.confirmPassword} @@ -51,7 +55,7 @@ const InitialStep = props => { { !props.install.isLoading ? - + : } diff --git a/client/coral-admin/src/containers/Install/components/Steps/FinalStep.js b/client/coral-admin/src/containers/Install/components/Steps/FinalStep.js index e549ebe63..0ad918487 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/FinalStep.js +++ b/client/coral-admin/src/containers/Install/components/Steps/FinalStep.js @@ -3,16 +3,16 @@ import styles from './style.css'; import {Button} from 'coral-ui'; import {Link} from 'react-router'; +const lang = new I18n(translations); +import translations from '../../translations.json'; +import I18n from 'coral-framework/modules/i18n/i18n'; + const InitialStep = () => { return (
-

- Thanks for installing Talk! We sent an email to verify your email - address. While you finish setting the account, you can start engaging - with your readers now. -

- - +

{lang.t('FINAL.DESCRIPTION')}

+ +
); }; diff --git a/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js b/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js index 9f9770fe9..1838e3178 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js +++ b/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js @@ -2,16 +2,16 @@ import React from 'react'; import styles from './style.css'; import {Button} from 'coral-ui'; +const lang = new I18n(translations); +import translations from '../../translations.json'; +import I18n from 'coral-framework/modules/i18n/i18n'; + const InitialStep = props => { const {nextStep} = props; return (
-

- The remainder of the Talk installation will take about ten minutes. - Once you complete the following two steps, you will have a free - installation and provision of Mongo and Redis. -

- +

{lang.t('INITIAL.DESCRIPTION')}

+
); }; diff --git a/client/coral-admin/src/containers/Install/components/Steps/PermittedDomainsStep.js b/client/coral-admin/src/containers/Install/components/Steps/PermittedDomainsStep.js new file mode 100644 index 000000000..f2c1546f6 --- /dev/null +++ b/client/coral-admin/src/containers/Install/components/Steps/PermittedDomainsStep.js @@ -0,0 +1,31 @@ +import React from 'react'; +import styles from './style.css'; +import {Button, Card} from 'coral-ui'; +import TagsInput from 'react-tagsinput'; + +const lang = new I18n(translations); +import translations from '../../translations.json'; +import I18n from 'coral-framework/modules/i18n/i18n'; + +const PermittedDomainsStep = props => { + const {finishInstall, install, handleDomainsChange} = props; + const domains = install.data.settings.domains.whitelist; + return ( +
+

{lang.t('PERMITTED_DOMAINS.TITLE')}

+ +

{lang.t('PERMITTED_DOMAINS.DESCRIPTION')}

+ data.split(',').map(d => d.trim())} + onChange={tags => handleDomainsChange(tags)} + /> +
+ +
+ ); +}; + +export default PermittedDomainsStep; diff --git a/client/coral-admin/src/containers/Install/components/Steps/style.css b/client/coral-admin/src/containers/Install/components/Steps/style.css index a5867e960..dea752298 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/style.css +++ b/client/coral-admin/src/containers/Install/components/Steps/style.css @@ -59,6 +59,12 @@ } } } + + .card { + max-width: 500px; + margin: 20px auto; + text-align: left; + } } .finalStep { diff --git a/client/coral-admin/src/containers/Install/translations.json b/client/coral-admin/src/containers/Install/translations.json new file mode 100644 index 000000000..e5b9f1542 --- /dev/null +++ b/client/coral-admin/src/containers/Install/translations.json @@ -0,0 +1,58 @@ +{ + "en": { + "INITIAL" : { + "DESCRIPTION": "Let's set up your Talk community in just a few short steps.", + "SUBMIT": "Get Started" + }, + "ADD_ORGANIZATION": { + "DESCRIPTION": "Please tell us the name of your organization. This will appear in emails when inviting new team members.", + "LABEL": "Organization Name", + "SAVE": "Save" + }, + "CREATE": { + "EMAIL": "Email address", + "USERNAME": "Username", + "PASSWORD": "Password", + "CONFIRM_PASSWORD": "Confirm Password", + "SAVE": "Save" + }, + "PERMITTED_DOMAINS": { + "TITLE": "Permitted domains", + "DESCRIPTION": "Enter the domains you would like to permit for Talk, e.g. your local, staging and production environments (ex. localhost:3000, staging.domain.com, domain.com).", + "SUBMIT": "Finish install" + }, + "FINAL": { + "DESCRIPTION": "Thanks for installing Talk! We sent an email to verify your email address. While you finish setting up the account, you can start engaging with your readers now.", + "LAUNCH": "Launch Talk", + "CLOSE": "Close this Installer" + } + }, + "es": { + "INITIAL" : { + "DESCRIPTION": "Configuremos tu comunidad de Talk en sólo algunos pasos.", + "SUBMIT": "Empezá!" + }, + "ADD_ORGANIZATION": { + "DESCRIPTION": "Por favor, dinos el nombre de tu organización. Este aparecerá en los emails cuando invites nuevos miembros.", + "LABEL": "Nombre de la Organización", + "SAVE": "Guardar" + }, + "CREATE": { + "EMAIL": "Dirección de E-Mail", + "USERNAME": "Usuario", + "PASSWORD": "Contraseña", + "CONFIRM_PASSWORD": "Confirmar contraseña", + "SAVE": "Guardar" + }, + "PERMITTED_DOMAINS": { + "TITLE": "Dominios Permitidos", + "DESCRIPTION": "Agrega dominios permitidos a Talk, e.g. tu localhost, staging y ambientes de production (ex. localhost:3000, staging.domain.com, domain.com).", + "SUBMIT": "Finalizar instalación" + }, + "FINAL": { + "DESCRIPTION": "Gracias por instalar Talk! Te enviamos un email para verificar tu identidad. Mientras se termina de configurar la cuenta, ya puedes empezar a interactuar con tus lectores", + "LAUNCH": "Lanzar Talk", + "CLOSE": "Cerrar este instalador" + } + } +} diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 19e6e7034..40f19b5a1 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -80,6 +80,7 @@ class ModerationContainer extends Component { { key={i} index={i} comment={comment} + commentType={props.activeTab} suspectWords={props.suspectWords} actions={actionsMap[status]} showBanUserDialog={props.showBanUserDialog} diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.css b/client/coral-admin/src/containers/ModerationQueue/components/Comment.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index b39eeb2f8..3a82af2cd 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -6,8 +6,10 @@ import {Link} from 'react-router'; import styles from './styles.css'; import {Icon} from 'coral-ui'; -import ActionButton from '../../../components/ActionButton'; import FlagBox from './FlagBox'; +import CommentType from './CommentType'; +import ActionButton from 'coral-admin/src/components/ActionButton'; +import BanUserButton from 'coral-admin/src/components/BanUserButton'; const linkify = new Linkify(); @@ -21,46 +23,48 @@ const Comment = ({actions = [], ...props}) => { return (
  • -
    +
    +
    {props.comment.user.name} - {timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} - - {props.comment.action_summaries ?

    {lang.t('comment.flagged')}

    : null} + {timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + + props.showBanUserDialog(props.comment.user, props.comment.id)} /> +
    {links ? Contains Link : null} -
    - {actions.map((action, i) => - props.acceptComment({commentId: props.comment.id})} - rejectComment={() => props.rejectComment({commentId: props.comment.id})} - showBanUserDialog={() => props.showBanUserDialog(props.comment.user, props.comment.id)} - /> - )} -
    +
    + {actions.map((action, i) => + props.acceptComment({commentId: props.comment.id})} + rejectComment={() => props.rejectComment({commentId: props.comment.id})} + /> + )} +
    {props.comment.user.status === 'banned' ? - + {lang.t('comment.banned_user')} - - : null} + + : null}
    - {!props.currentAsset && ( -
    - Article: {props.comment.asset.title} Moderate Article + {!props.currentAsset && ( +
    + Story: {props.comment.asset.title} Moderate → +
    + )} +
    +

    + + + +

    - )} -
    -

    - - - -

    {actionSummaries && }
  • @@ -72,7 +76,6 @@ Comment.propTypes = { rejectComment: PropTypes.func.isRequired, suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, currentAsset: PropTypes.object, - isActive: PropTypes.bool.isRequired, comment: PropTypes.shape({ body: PropTypes.string.isRequired, action_summaries: PropTypes.array, diff --git a/client/coral-admin/src/containers/ModerationQueue/components/CommentType.css b/client/coral-admin/src/containers/ModerationQueue/components/CommentType.css new file mode 100644 index 000000000..cdfd212ff --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/CommentType.css @@ -0,0 +1,33 @@ +.commentType { + position: absolute; + right: 15px; + top: 11px; + color: white; + background: grey; + padding: 2px 13px; + font-size: 14px; + height: 32px; + box-sizing: border-box; + line-height: 29px; + padding-left: 26px; + + i { + font-size: 14px; + position: absolute; + left: 6px; + top: 8px; + margin: 0; + } + + &.premod { + background: #063B9A; + } + + &.flagged { + background: #d03235; + } + + &.no-type { + display: none; + } +} diff --git a/client/coral-admin/src/containers/ModerationQueue/components/CommentType.js b/client/coral-admin/src/containers/ModerationQueue/components/CommentType.js new file mode 100644 index 000000000..5ad4138ca --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/CommentType.js @@ -0,0 +1,30 @@ +import React, {PropTypes} from 'react'; +import styles from './CommentType.css'; +import {Icon} from 'coral-ui'; + +const CommentType = props => { + const typeData = getTypeData(props.type); + + return ( + + {typeData.text} + + ); +}; + +const getTypeData = type => { + switch (type) { + case 'premod': + return {icon: 'query_builder', text: 'Pre-Mod', className: 'premod'}; + case 'flagged': + return {icon: 'flag', text: 'Flagged', className: 'flagged'}; + default: + return {icon: 'flag', className: 'no-type'}; + } +}; + +CommentType.propTypes = { + type: PropTypes.string.isRequired +}; + +export default CommentType; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.css b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.css new file mode 100644 index 000000000..fa7bb0cca --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.css @@ -0,0 +1,55 @@ +.flagBox { + border-top: 1px solid rgba(66, 66, 66, 0.12); + + .container { + padding: 0 14px; + } + + .detail { + padding: 0 20px 16px; + ul { + padding: 0; + list-style: none; + font-size: 12px; + font-weight: 500; + } + } + + .header { + position: relative; + .moreDetail { + float: right; + font-size: 12px; + font-weight: 500; + margin-right: 10px; + margin-top: 8px; + color: black; + + &:hover { + opacity: 0.9; + cursor: pointer; + } + } + i { + vertical-align: middle; + font-size: 12px; + } + ul { + vertical-align: middle; + list-style: none; + display: inline-block; + padding: 0; + margin-left: 10px; + font-size: 12px; + } + } + + h3 { + vertical-align: middle; + margin: 0; + font-weight: 500; + display: inline-block; + margin-left: 7px; + font-size: 12px; + } +} diff --git a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js index 6140abd90..00b7d4273 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js @@ -1,16 +1,47 @@ -import React, {PropTypes} from 'react'; -import styles from './styles.css'; +import React, {Component, PropTypes} from 'react'; +import {Icon} from 'coral-ui'; +import styles from './FlagBox.css'; -const FlagBox = props => ( -
    -

    Flags:

    -
      - {props.actionSummaries.map((action, i) => -
    • {!action.reason ? No reason provided : action.reason} ({action.count})
    • - )} -
    -
    -); +class FlagBox extends Component { + constructor () { + super(); + this.state = { + showDetail: false + }; + } + + toggleDetail = () => { + this.setState((state) => ({ + showDetail: !state.showDetail + })); + } + + render() { + const {props} = this; + return ( +
    +
    +
    +

    Flags ({props.actionSummaries.length}):

    +
      + {props.actionSummaries.map((action, i) => +
    • {!action.reason ? No reason provided : action.reason} ({action.count})
    • + )} +
    + {/* More detail*/} +
    + {this.state.showDetail && (
    +
      + {props.actionSummaries.map((action, i) => +
    • {!action.reason ? No reason provided : action.reason} ({action.count})
    • + )} +
    +
    )} +
    +
    + ); + } +} FlagBox.propTypes = { actionSummaries: PropTypes.array.isRequired diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css index 27d5c2533..6db537eef 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/styles.css +++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css @@ -162,16 +162,21 @@ span { .listItem { border-bottom: 1px solid #e0e0e0; - font-size: 16px; + font-size: 18px; width: 100%; max-width: 660px; min-width: 400px; margin: 0 auto; - padding: 16px 14px; position: relative; transition: box-shadow 200ms; margin-top: 0; + padding: 4px 0 0; + min-height: 220px; + .container { + padding: 0 14px; + min-height: 220px; + } &:hover { box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); @@ -194,7 +199,7 @@ span { right: 0; height: 100%; top: 0; - padding: 40px 18px; + padding: 100px 12px 65px; box-sizing: border-box; } @@ -213,6 +218,8 @@ span { .itemBody { display: flex; justify-content: space-between; + margin-top: 15px; + font-size: 16px; } .avatar { @@ -228,7 +235,7 @@ span { .created { color: #666; font-size: 13px; - margin-left: 40px; + margin-left: 15px; } .actionButton { @@ -310,20 +317,26 @@ span { } .Comment { + .moderateArticle { - font-size: 12px; + font-size: 15px; + margin-top: 14px; + font-weight: 500; a { display: inline-block; - color: #679af3; + color: #063b9a; text-decoration: none; - font-size: 1em; - font-weight: 400; + font-weight: 500; letter-spacing: .5px; - font-size: 12px; margin-left: 10px; + font-size: 13px; + margin-left: 5px; + padding-bottom: 0px; + border-bottom: solid 1px; + line-height: 16px; + &:hover { - text-decoration: underline; opacity: .9; cursor: pointer; } @@ -331,16 +344,6 @@ span { } } -.flagBox { - max-width: 480px; - border-top: 1px solid rgba(66, 66, 66, 0.12); - h3 { - font-size: 14px; - margin: 0; - font-weight: 500; - } -} - .selectField { position: relative; width: 140px; diff --git a/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js b/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js index d0ac64070..09066831f 100644 --- a/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js +++ b/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js @@ -1,13 +1,14 @@ export const actionsMap = { - PREMOD: ['REJECT', 'APPROVE', 'BAN'], - FLAGGED: ['REJECT', 'APPROVE', 'BAN'], - REJECTED: ['APPROVE'] + PREMOD: ['APPROVE', 'REJECT'], + FLAGGED: ['APPROVE', 'REJECT'], + REJECTED: ['APPROVE', 'REJECTED'] }; export const menuActionsMap = { - 'REJECT': {status: 'REJECTED', icon: 'close', key: 'r'}, - 'APPROVE': {status: 'ACCEPTED', icon: 'done', key: 't'}, - 'FLAGGED': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'}, - 'BAN': {status: 'BANNED', icon: 'not interested'}, + 'REJECT': {status: 'REJECTED', text: 'Reject', icon: 'close', key: 'r'}, + 'REJECTED': {status: 'REJECTED', text: 'Rejected', icon: 'close'}, + 'APPROVE': {status: 'ACCEPTED', text: 'Approve', icon: 'done', key: 't'}, + 'FLAGGED': {status: 'FLAGGED', text: 'Flag', icon: 'flag', filter: 'Untouched'}, + 'BAN': {status: 'BANNED', text: 'Ban User', icon: 'not interested'}, '': {icon: 'done'} }; diff --git a/client/coral-admin/src/reducers/install.js b/client/coral-admin/src/reducers/install.js index 596fd16cd..1f0f079ff 100644 --- a/client/coral-admin/src/reducers/install.js +++ b/client/coral-admin/src/reducers/install.js @@ -1,4 +1,4 @@ -import {Map} from 'immutable'; +import {Map, List} from 'immutable'; import * as actions from '../constants/install'; @@ -6,7 +6,10 @@ const initialState = Map({ isLoading: false, data: Map({ settings: Map({ - organizationName: '' + organizationName: '', + domains: Map({ + whitelist: List() + }) }), user: Map({ username: '', @@ -33,6 +36,10 @@ const initialState = Map({ { text: '2. Create your account', step: 2 + }, + { + text: '3. Domain Whitelist', + step: 3 }], installRequest: null, installRequestError: null, @@ -50,6 +57,9 @@ export default function install (state = initialState, action) { case actions.GO_TO_STEP: return state .set('step', action.step); + case actions.UPDATE_PERMITTED_DOMAINS_SETTINGS: + return state + .setIn(['data', 'settings', 'domains', 'whitelist'], action.value); case actions.UPDATE_FORMDATA_SETTINGS: return state .setIn(['data', 'settings', action.name], action.value); diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js index 40a539634..7f6a0567d 100644 --- a/client/coral-admin/src/services/client.js +++ b/client/coral-admin/src/services/client.js @@ -2,6 +2,7 @@ import ApolloClient, {addTypename} from 'apollo-client'; import getNetworkInterface from './transport'; export const client = new ApolloClient({ + addTypename: true, queryTransformer: addTypename, dataIdFromObject: (result) => { if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 92e5d098a..dbb3e650e 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -86,7 +86,7 @@ "comment-count-text-post": " characters.", "comment-count-error": "Please enter a valid number.", "domain-list-title": "Domain Whitelist", - "domain-list-text": "Some instructions on how to type the urls." + "domain-list-text": "Enter the domains you would like to permit for Talk, e.g. your local, staging and production environments (ex. localhost:3000, staging.domain.com, domain.com)." }, "bandialog": { "ban_user": "Ban User?", @@ -207,7 +207,7 @@ "comment-count-text-post": " caracteres", "comment-count-error": "Por favor escribe un número válido.", "domain-list-title": "Lista de Dominios Permitidos", - "domain-list-text": "Instrucciones de como ingresar las URLs." + "domain-list-text": "Agrega dominios permitidos a Talk, e.g. tu localhost, staging y ambientes de production (ex. localhost:3000, staging.domain.com, domain.com)." }, "embedlink": { "copy": "Copiar" diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js index e52c4aff4..8a81f9fe6 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/Comment.js @@ -43,6 +43,7 @@ class Comment extends React.Component { postLike: PropTypes.func.isRequired, deleteAction: PropTypes.func.isRequired, parentId: PropTypes.string, + highlighted: PropTypes.string, addNotification: PropTypes.func.isRequired, postItem: PropTypes.func.isRequired, depth: PropTypes.number.isRequired, @@ -87,6 +88,7 @@ class Comment extends React.Component { addNotification, showSignInDialog, postLike, + highlighted, postFlag, postDontAgree, loadMore, @@ -98,10 +100,12 @@ class Comment extends React.Component { const like = getActionSummary('LikeActionSummary', comment); const flag = getActionSummary('FlagActionSummary', comment); const dontagree = getActionSummary('DontAgreeActionSummary', comment); + let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`; + commentClass += highlighted === comment.id ? ' highlighted-comment' : ''; return (

    @@ -163,6 +167,7 @@ class Comment extends React.Component { postItem={postItem} depth={depth + 1} asset={asset} + highlighted={highlighted} currentUser={currentUser} postLike={postLike} postFlag={postFlag} diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js index d44c44086..d016af137 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -31,12 +31,13 @@ import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUserna import ProfileContainer from 'coral-settings/containers/ProfileContainer'; import RestrictedContent from 'coral-framework/components/RestrictedContent'; import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer'; +import Comment from './Comment'; import LoadMore from './LoadMore'; import NewCount from './NewCount'; class Embed extends Component { - state = {activeTab: 0, showSignInDialog: false}; + state = {activeTab: 0, showSignInDialog: false, activeReplyBox: ''}; changeTab = (tab) => { @@ -59,23 +60,6 @@ class Embed extends Component { componentDidMount () { pym.sendMessage('childReady'); - - pym.onMessage('DOMContentLoaded', hash => { - const commentId = hash.replace('#', 'c_'); - let count = 0; - const interval = setInterval(() => { - if (document.getElementById(commentId)) { - window.clearInterval(interval); - pym.scrollParentToChildEl(commentId); - } - - if (++count > 100) { // ~10 seconds - // give up waiting for the comments to load. - // it would be weird for the page to jump after that long. - window.clearInterval(interval); - } - }, 100); - }); } componentWillReceiveProps (nextProps) { @@ -85,11 +69,29 @@ class Embed extends Component { } } + componentDidUpdate(prevProps) { + if(!isEqual(prevProps.data.comment, this.props.data.comment)) { + + // Scroll to a permalinked comment if one is in the URL once the page is done rendering. + setTimeout(()=>pym.scrollParentToChildEl(`c_${this.props.data.comment.id}`), 0); + } + } + + setActiveReplyBox (reactKey) { + if (!this.props.currentUser) { + const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75; + this.props.showSignInDialog(offset); + } else { + this.setState({activeReplyBox: reactKey}); + } + } + render () { const {activeTab} = this.state; const {closedAt, countCache = {}} = this.props.asset; - const {loading, asset, refetch} = this.props.data; + const {loading, asset, refetch, comment} = this.props.data; const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth; + const highlightedComment = comment && comment.parent ? comment.parent : comment; const openStream = closedAt === null; @@ -162,6 +164,28 @@ class Embed extends Component { refetch={refetch} offset={signInOffset}/>} {loggedIn && user && } + { + highlightedComment && + + } tag - * (including copypasta dependencies like pym.js), but later there will be a - * build step and this code may use import statements - */ - -// using umd.js (https://github.com/umdjs/umd/blob/master/templates/returnExports.js) -(function (root, factory) { - /* eslint-disable */ - if (typeof define === 'function' && define.amd) { - - // AMD. Register as an anonymous module. - define([], factory); - } else if (typeof module === 'object' && module.exports) { - - // Node. Does not work with strict CommonJS, but - // only CommonJS-like environments that support module.exports, - // like Node. - module.exports = factory(); - } else { - - // Browser globals (root is window) - root.Coral = factory(); - } - /* eslint-enable */ -}(this, function () { - - // This function should return value of window.Coral - var pym = requirePym(); - var Coral = {}; - var Talk = Coral.Talk = {}; - - /** - * Render a Talk stream - * @param {HTMLElement} el - Element to render the stream in - * @param {Object} opts - Configuration options for talk - * @param {String} opts.talk - Talk base URL - * @param {String} [opts.title] - Title of Stream (rendered in iframe) - * @param {String} [opts.asset] - parent Asset ID or URL. Comments in the - * stream will replies to this asset - */ - Talk.render = function (el, opts) { - if ( ! el) { - throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'); - } - if (typeof el !== 'object') { - throw new Error('Coral.Talk.render() expected HTMLElement but got ' + el + ' (' + typeof el + ')'); - } - opts = opts || {}; - - // @todo infer this URL without explicit user input (if possible, may have to be added at build/render time of this script) - if (! opts.talk) { - throw new Error('Coral.Talk.render() expects opts.talk as the Talk Base URL'); - } - - // ensure el has an id, as pym can't directly accept the HTMLElement - if ( ! el.id) {el.id = '_' + String(Math.random());} - var asset = opts.asset || window.location; - var pymParent = new pym.Parent( - el.id, - buildStreamIframeUrl(opts.talk, asset), - { - title: opts.title, - asset_url: asset, - id: el.id + '_iframe', - name: el.id + '_iframe' - } - ); - - configurePymParent(pymParent, asset); - }; - - return Coral; - - // build the URL to load in the pym iframe - function buildStreamIframeUrl(talkBaseUrl, asset) { - var iframeUrl = [ - talkBaseUrl, - (talkBaseUrl.match(/\/$/) ? '' : '/'), // make sure no double-'/' if opts.talk already ends with '/' - 'embed/stream?asset_url=', - encodeURIComponent(asset) - ].join(''); - return iframeUrl; - } - - // Set up postMessage listeners/handlers on the pymParent - // e.g. to resize the iframe, and navigate the host page - function configurePymParent(pymParent, assetUrl) { - var notificationOffset = 200; - var ready = false; - - // Resize parent iframe height when child height changes - pymParent.onMessage('height', function(height) { - - // TODO: In local testing, this is firing nonstop. Maybe there's a bug on the inside? - // Or it's by design of pym... but that's very wasteful of CPU and DOM reflows (jank) - pymParent.el.querySelector('iframe').height = height + 'px'; - }); - - // Helps child show notifications at the right scrollTop - pymParent.onMessage('getPosition', function() { - var position = viewport().height + document.body.scrollTop; - - if (position > notificationOffset) { - position = position - notificationOffset; - } - - pymParent.sendMessage('position', position); - }); - - // Tell child when parent's DOMContentLoaded - pymParent.onMessage('childReady', function () { - var interval = setInterval(function () { - if (ready) { - window.clearInterval(interval); - - // @todo - It's weird to me that this is sent here in addition to the iframe URL. Could it just be in one place? - pymParent.sendMessage('DOMContentLoaded', assetUrl); - } - }, 100); - }); - - // When end-user clicks link in iframe, open it in parent context - pymParent.onMessage('navigate', function (url) { - window.open(url, '_blank').focus(); - }); - - // wait till images and other iframes are loaded before scrolling the page. - // or do we want to be more aggressive and scroll when we hit DOM ready? - document.addEventListener('DOMContentLoaded', function () { - ready = true; - }); - - // get dimensions of viewport - function viewport() { - var e = window, a = 'inner'; - if ( !( 'innerWidth' in window ) ){ - a = 'client'; - e = document.documentElement || document.body; - } - return { - width : e[a + 'Width'], - height : e[a + 'Height'] - }; - } - } - - // return a reference to pym.js - function requirePym() { - var pym; - - // fake AMD `define` so that the pym.js copypasta doesn't create a global - function define(createPym) { - pym = createPym(); - } - define.amd = true; - - /* eslint-disable */ - /*! pym.js - v1.1.2 - 2016-10-25 */ - !function(a){"function"==typeof define&&define.amd?define(a):"undefined"!=typeof module&&module.exports?module.exports=a():window.pym=a.call(this)}(function(){var a="xPYMx",b={},c=function(a){var b=new RegExp("[\\?&]"+a.replace(/[\[]/,"\\[").replace(/[\]]/,"\\]")+"=([^&#]*)"),c=b.exec(location.search);return null===c?"":decodeURIComponent(c[1].replace(/\+/g," "))},d=function(a,b){if("*"===b.xdomain||a.origin.match(new RegExp(b.xdomain+"$")))return!0},e=function(b,c,d){var e=["pym",b,c,d];return e.join(a)},f=function(b){var c=["pym",b,"(\\S+)","(.*)"];return new RegExp("^"+c.join(a)+"$")},g=function(){for(var a=b.autoInitInstances.length,c=a-1;c>=0;c--){var d=b.autoInitInstances[c];d.el.getElementsByTagName("iframe").length&&d.el.getElementsByTagName("iframe")[0].contentWindow||b.autoInitInstances.splice(c,1)}};return b.autoInitInstances=[],b.autoInit=function(){var a=document.querySelectorAll("[data-pym-src]:not([data-pym-auto-initialized])"),c=a.length;g();for(var d=0;d-1&&(b=this.url.substring(c,this.url.length),this.url=this.url.substring(0,c)),this.url.indexOf("?")<0?this.url+="?":this.url+="&",this.iframe.src=this.url+"initialWidth="+a+"&childId="+this.id+"&parentTitle="+encodeURIComponent(document.title)+"&parentUrl="+encodeURIComponent(window.location.href)+b,this.iframe.setAttribute("width","100%"),this.iframe.setAttribute("scrolling","no"),this.iframe.setAttribute("marginheight","0"),this.iframe.setAttribute("frameborder","0"),this.settings.title&&this.iframe.setAttribute("title",this.settings.title),void 0!==this.settings.allowfullscreen&&this.settings.allowfullscreen!==!1&&this.iframe.setAttribute("allowfullscreen",""),void 0!==this.settings.sandbox&&"string"==typeof this.settings.sandbox&&this.iframe.setAttribute("sandbox",this.settings.sandbox),this.settings.id&&(document.getElementById(this.settings.id)||this.iframe.setAttribute("id",this.settings.id)),this.settings.name&&this.iframe.setAttribute("name",this.settings.name);this.el.firstChild;)this.el.removeChild(this.el.firstChild);this.el.appendChild(this.iframe),window.addEventListener("resize",this._onResize)},this._onResize=function(){this.sendWidth()}.bind(this),this._fire=function(a,b){if(a in this.messageHandlers)for(var c=0;c notificationOffset) { + position = position - notificationOffset; + } + + pymParent.sendMessage('position', position); + }); + + // Tell child when parent's DOMContentLoaded + pymParent.onMessage('childReady', function () { + const interval = setInterval(function () { + if (ready) { + window.clearInterval(interval); + + // @todo - It's weird to me that this is sent here in addition to the iframe URL. Could it just be in one place? + pymParent.sendMessage('DOMContentLoaded', assetUrl); + } + }, 100); + }); + + // When end-user clicks link in iframe, open it in parent context + pymParent.onMessage('navigate', function (url) { + window.open(url, '_blank').focus(); + }); + + // wait till images and other iframes are loaded before scrolling the page. + // or do we want to be more aggressive and scroll when we hit DOM ready? + document.addEventListener('DOMContentLoaded', function () { + ready = true; + }); + + // get dimensions of viewport + const viewport = () => { + let e = window, a = 'inner'; + if ( !( 'innerWidth' in window ) ){ + a = 'client'; + e = document.documentElement || document.body; + } + return { + width : e[`${a}Width`], + height : e[`${a}Height`] + }; + }; +} + +/** + * Render a Talk stream + * @param {HTMLElement} el - Element to render the stream in + * @param {Object} opts - Configuration options for talk + * @param {String} opts.talk - Talk base URL + * @param {String} [opts.title] - Title of Stream (rendered in iframe) + * @param {String} [opts.asset] - parent Asset ID or URL. Comments in the + * stream will replies to this asset + */ +Talk.render = function (el, opts) { + if ( ! el) { + throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'); + } + if (typeof el !== 'object') { + throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`); + } + opts = opts || {}; + + // @todo infer this URL without explicit user input (if possible, may have to be added at build/render time of this script) + if (! opts.talk) { + throw new Error('Coral.Talk.render() expects opts.talk as the Talk Base URL'); + } + + // ensure el has an id, as pym can't directly accept the HTMLElement + if (!el.id) { + el.id = `_${Math.random()}`; + } + + let asset = opts.asset || window.location.href.split('#')[0]; + let comment = window.location.hash.slice(1); + let pymParent = new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, asset, comment), { + title: opts.title, + asset_url: asset, + id: `${el.id}_iframe`, + name: `${el.id}_iframe` + }); + + configurePymParent(pymParent, asset); +}; + +export default Coral; diff --git a/client/coral-framework/graphql/queries/commentQuery.graphql b/client/coral-framework/graphql/queries/commentQuery.graphql new file mode 100644 index 000000000..83f92b8f5 --- /dev/null +++ b/client/coral-framework/graphql/queries/commentQuery.graphql @@ -0,0 +1,13 @@ +#import "../fragments/commentView.graphql" + +query commentQuery($id: ID!) { + comment(id: $id) { + ...commentView + parent { + ...commentView + replies { + ...commentView + } + } + } +} diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js index c993dfb6a..26a72498d 100644 --- a/client/coral-framework/graphql/queries/index.js +++ b/client/coral-framework/graphql/queries/index.js @@ -87,7 +87,8 @@ export const loadMore = (data) => ({limit, cursor, parent_id, asset_id, sort}, n export const queryStream = graphql(STREAM_QUERY, { options: () => ({ variables: { - asset_url: getQueryVariable('asset_url') + asset_url: getQueryVariable('asset_url'), + comment_id: getQueryVariable('comment_id') } }), props: ({data}) => ({ diff --git a/client/coral-framework/graphql/queries/streamQuery.graphql b/client/coral-framework/graphql/queries/streamQuery.graphql index 0a7f6c549..4f09f628e 100644 --- a/client/coral-framework/graphql/queries/streamQuery.graphql +++ b/client/coral-framework/graphql/queries/streamQuery.graphql @@ -1,6 +1,15 @@ #import "../fragments/commentView.graphql" -query AssetQuery($asset_url: String!) { +query AssetQuery($asset_url: String!, $comment_id: ID!) { + comment(id: $comment_id) { + ...commentView + parent { + ...commentView + replies { + ...commentView + } + } + } asset(url: $asset_url) { id title diff --git a/client/coral-ui/components/Button.css b/client/coral-ui/components/Button.css index 4cdd7ef74..498b03912 100644 --- a/client/coral-ui/components/Button.css +++ b/client/coral-ui/components/Button.css @@ -110,6 +110,82 @@ background: #00a291; } +.type--approve { + display: block; + color: #519954; + border: solid 2px rgba(81, 153, 84, 0.75); + background: white; + padding: 10px 12px; + box-sizing: border-box; + vertical-align: middle; + line-height: 24px; + font-size: 17px; + height: 47px; + border-radius: 3px; + text-transform: capitalize; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09); + width: 128px; + + &:hover { + box-shadow: none; + color: white; + background-color: #519954; + } +} + +.type--reject, .type--rejected { + display: block; + color: #D03235; + border: solid 1px #D03235; + background: white; + padding: 10px 11px; + box-sizing: border-box; + vertical-align: middle; + line-height: 24px; + font-size: 17px; + height: 47px; + border-radius: 3px; + text-transform: capitalize; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09); + width: 128px; + +&:hover { + color: white; + background-color: #D03235; + box-shadow: none; + } +} + +.type--rejected { + color: white; + background-color: #D03235; + box-shadow: none; + cursor: not-allowed; +} + +.type--ban { + display: block; + color: #616161; + border: solid 2px rgba(97, 97, 97, 0.77); + background: white; + padding: 10px 12px; + box-sizing: border-box; + vertical-align: middle; + line-height: 24px; + font-size: 17px; + height: 47px; + border-radius: 3px; + text-transform: capitalize; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09); + width: 128px; + +&:hover { + box-shadow: none; + color: white; + background-color: #616161; + } +} + .full { width: 100%; margin: 0; diff --git a/client/coral-ui/components/WizardNav.css b/client/coral-ui/components/WizardNav.css index 001ee8603..1f0b78d86 100644 --- a/client/coral-ui/components/WizardNav.css +++ b/client/coral-ui/components/WizardNav.css @@ -13,6 +13,7 @@ position: relative; padding-left: 40px; border-radius: 1px; + min-height: 24px; &:hover { cursor: pointer; diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 47a8095dc..afa79ea5c 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -39,7 +39,7 @@ const getCountsByAssetID = (context, asset_ids) => { /** * Returns the comment count for all comments that are public based on their * parent ids. - * @param {Object} context graph context + * * @param {Array} parent_ids the ids of parents for which there are * comments that we want to get */ @@ -271,18 +271,30 @@ const genRecentComments = (_, ids) => { }; /** - * Returns the comment's by their id. + * genComments returns the comments by the id's. Only admins can see non-public comments. * @param {Object} context graph context * @param {Array} ids the comment id's to fetch * @return {Promise} resolves to the comments */ -const genCommentsByID = (context, ids) => { - return CommentModel.find({ - id: { - $in: ids - } - }) - .then(util.singleJoinBy(ids, 'id')); +const genComments = ({user}, ids) => { + let comments; + if (user && user.hasRoles('ADMIN')) { + comments = CommentModel.find({ + id: { + $in: ids + } + }); + } else { + comments = CommentModel.find({ + id: { + $in: ids + }, + status: { + $in: ['NONE', 'ACCEPTED'] + } + }); + } + return comments.then(util.singleJoinBy(ids, 'id')); }; /** @@ -292,7 +304,7 @@ const genCommentsByID = (context, ids) => { */ module.exports = (context) => ({ Comments: { - get: new DataLoader((ids) => genCommentsByID(context, ids)), + get: new DataLoader((ids) => genComments(context, ids)), getByQuery: (query) => getCommentsByQuery(context, query), getCountByQuery: (query) => getCommentCountByQuery(context, query), countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)), diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index de1909df1..2752ad0e3 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,4 +1,11 @@ const Comment = { + parent({parent_id}, _, {loaders: {Comments}}) { + if (parent_id == null) { + return null; + } + + return Comments.get.load(parent_id); + }, user({author_id}, _, {loaders: {Users}}) { return Users.getByID.load(author_id); }, diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 8800bfebf..bdcdcef78 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -38,7 +38,9 @@ const RootQuery = { return Comments.getByQuery(query); }, - + comment(_, {id}, {loaders: {Comments}}) { + return Comments.get.load(id); + }, commentCount(_, {query: {action_type, statuses, asset_id, parent_id}}, {user, loaders: {Actions, Comments}}) { if (user == null || !user.hasRoles('ADMIN')) { return null; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 4dd9a4ba8..92f0d41e2 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -149,6 +149,9 @@ input CommentCountQuery { # Comment is the base representation of user interaction in Talk. type Comment { + # The parent of the comment (if there is one). + parent: Comment + # The ID of the comment. id: ID! @@ -477,6 +480,9 @@ type RootQuery { # Site wide settings and defaults. settings: Settings + # Finds a specific comment based on it's id. + comment(id: ID!): Comment + # All assets. Requires the `ADMIN` role. assets: [Asset] diff --git a/package.json b/package.json index 70b0e17b1..cbf3a1267 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "scripts": { "start": "./bin/cli serve --jobs", "dev-start": "nodemon --config .nodemon.json --exec \"./bin/cli -c .env serve --jobs\"", - "build": "NODE_ENV=production webpack -p --config webpack.config.js --bail", - "build-watch": "NODE_ENV=development webpack --config webpack.config.js --watch", + "build": "NODE_ENV=production webpack --progress -p --config webpack.config.js --bail", + "build-watch": "NODE_ENV=development webpack --progress --config webpack.config.js --watch", "lint": "eslint bin/* .", "lint-fix": "eslint bin/* . --fix", "test": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}", @@ -130,6 +130,7 @@ "jsdom": "^9.8.3", "json-loader": "^0.5.4", "keymaster": "^1.6.2", + "license-webpack-plugin": "^0.4.2", "material-design-lite": "^1.2.1", "mocha": "^3.1.2", "mocha-junit-reporter": "^1.12.1", diff --git a/routes/index.js b/routes/index.js index add288b10..b1e61659a 100644 --- a/routes/index.js +++ b/routes/index.js @@ -5,16 +5,20 @@ const router = express.Router(); router.use('/api/v1', require('./api')); router.use('/admin', require('./admin')); router.use('/embed', require('./embed')); -router.get('/embed.js', (req, res) => res.sendFile(path.join(__dirname, '../client/coral-embed/index.js'))); -router.use('/assets', require('./assets')); +router.get('/embed.js', (req, res) => res.sendFile(path.join(__dirname, '../dist/embed.js'))); +router.get('/embed.js.map', (req, res) => res.sendFile(path.join(__dirname, '../dist/embed.js.map'))); -router.get('/', (req, res) => { - return res.render('article', { - title: 'Coral Talk', - asset_url: '', - body: '', - basePath: '/client/embed/stream' +if (process.env.NODE_ENV !== 'production') { + router.use('/assets', require('./assets')); + + router.get('/', (req, res) => { + return res.render('article', { + title: 'Coral Talk', + asset_url: '', + body: '', + basePath: '/client/embed/stream' + }); }); -}); +} module.exports = router; diff --git a/webpack.config.js b/webpack.config.js index 5a7bb297b..cd00696d8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,6 +2,7 @@ const path = require('path'); const autoprefixer = require('autoprefixer'); const precss = require('precss'); const Copy = require('copy-webpack-plugin'); +const LicenseWebpackPlugin = require('license-webpack-plugin'); const webpack = require('webpack'); // Edit the build targets and embeds below. @@ -17,7 +18,12 @@ const buildEmbeds = [ module.exports = { devtool: 'cheap-source-map', - entry: buildTargets.reduce((entry, target) => { + entry: Object.assign({}, { + 'embed': [ + 'babel-polyfill', + path.join(__dirname, 'client/coral-embed/src/index') + ] + }, buildTargets.reduce((entry, target) => { // Add the entry for the bundle. entry[`${target}/bundle`] = [ @@ -26,7 +32,7 @@ module.exports = { ]; return entry; - }, buildEmbeds.reduce((entry, embed) => { + }, {}), buildEmbeds.reduce((entry, embed) => { // Add the entry for the bundle. entry[`embed/${embed}/bundle`] = [ @@ -38,14 +44,22 @@ module.exports = { }, {})), output: { path: path.join(__dirname, 'dist'), - filename: '[name].js' + publicPath: '/client/', + filename: '[name].js', + + // NOTE: this causes all exports to override the global.Coral, so no more + // than one bundle.js can be included on a page. + library: 'Coral' }, module: { rules: [ { loader: 'babel-loader', exclude: /node_modules/, - test: /\.js$/ + test: /\.js$/, + query: { + cacheDirectory: true + } }, { loader: 'json-loader', @@ -80,6 +94,10 @@ module.exports = { ] }, plugins: [ + new LicenseWebpackPlugin({ + pattern: /^(MIT|ISC|BSD.*)$/, + addUrl: true + }), new Copy([ ...buildEmbeds.map(embed => ({ from: path.join(__dirname, 'client', `coral-embed-${embed}`, 'style'), diff --git a/yarn.lock b/yarn.lock index 5413439fa..0331202ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4385,6 +4385,12 @@ libqp@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/libqp/-/libqp-1.1.0.tgz#f5e6e06ad74b794fb5b5b66988bf728ef1dedbe8" +license-webpack-plugin@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-0.4.2.tgz#188df5418a72ab61cad648b9ffd9355be270242b" + dependencies: + object-assign "^4.1.0" + linkify-it@^1.2.0: version "1.2.4" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-1.2.4.tgz#0773526c317c8fd13bd534ee1d180ff88abf881a"