diff --git a/.gitignore b/.gitignore index a619f0988..f0c3b2a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,7 @@ plugins/* !plugins/coral-plugin-respect !plugins/coral-plugin-offtopic !plugins/coral-plugin-like +!plugins/coral-plugin-mod +!plugins/coral-plugin-love **/node_modules/* diff --git a/LICENSE b/LICENSE index 41cbbd5aa..597d8fa73 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2016 Mozilla Foundation +Copyright 2017 Mozilla Foundation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 74097d48c..09cd33114 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) -Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html) +Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [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. diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index ffd4db2a6..55a00096d 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -1,16 +1,17 @@ import React, {PropTypes} from 'react'; import timeago from 'timeago.js'; -import Linkify from 'react-linkify'; -import Highlighter from 'react-highlight-words'; import {Link} from 'react-router'; +import Linkify from 'react-linkify'; -import styles from './styles.css'; import {Icon} from 'coral-ui'; import FlagBox from './FlagBox'; +import styles from './styles.css'; import CommentType from './CommentType'; +import Highlighter from 'react-highlight-words'; +import Slot from 'coral-framework/components/Slot'; +import {getActionSummary} from 'coral-framework/utils'; import ActionButton from 'coral-admin/src/components/ActionButton'; import BanUserButton from 'coral-admin/src/components/BanUserButton'; -import {getActionSummary} from 'coral-framework/utils'; const linkify = new Linkify(); @@ -18,11 +19,19 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-admin/src/translations.json'; const lang = new I18n(translations); -const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) => { +const Comment = ({ + actions = [], + comment, + suspectWords, + bannedWords, + ...props +}) => { const links = linkify.getMatches(comment.body); const linkText = links ? links.map(link => link.raw) : []; const flagActionSummaries = getActionSummary('FlagActionSummary', comment); - const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction'); + const flagActions = + comment.actions && + comment.actions.filter(a => a.__typename === 'FlagAction'); let commentType = ''; if (comment.status === 'PREMOD') { commentType = 'premod'; @@ -33,12 +42,17 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) = // since words are checked against word boundaries on the backend, // this should be the behavior on the front end as well. // currently the highlighter plugin does not support this out of the box. - const searchWords = [...suspectWords, ...bannedWords].filter(w => { - return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); - }).concat(linkText); + const searchWords = [...suspectWords, ...bannedWords] + .filter(w => { + return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); + }) + .concat(linkText); return ( -
  • +
  • @@ -46,53 +60,95 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) = {comment.user.name} - {timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + {timeago().format( + comment.created_at || Date.now() - props.index * 60 * 1000, + lang.getLocale().replace('-', '_') + )} - props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')} /> + + props.showBanUserDialog( + comment.user, + comment.id, + comment.status, + comment.status !== 'REJECTED' + )} + />
    - {comment.user.status === 'banned' ? - - - {lang.t('comment.banned_user')} - + {comment.user.status === 'banned' + ? + + {lang.t('comment.banned_user')} + : null} +
    Story: {comment.asset.title} - {!props.currentAsset && ( - Moderate → - )} + {!props.currentAsset && + Moderate →}

    {lang.t('comment.view_context')} + textToHighlight={comment.body} + /> + {' '} + + {lang.t('comment.view_context')} +

    +
    - {links ? Contains Link : null} + {links + ? + Contains Link + + : null}
    {actions.map((action, i) => { - const active = (action === 'REJECT' && comment.status === 'REJECTED') || - (action === 'APPROVE' && comment.status === 'ACCEPTED'); - return comment.status === 'ACCEPTED' ? null : props.acceptComment({commentId: comment.id})} - rejectComment={() => comment.status === 'REJECTED' ? null : props.rejectComment({commentId: comment.id})} />; + const active = + (action === 'REJECT' && comment.status === 'REJECTED') || + (action === 'APPROVE' && comment.status === 'ACCEPTED'); + return ( + + (comment.status === 'ACCEPTED' + ? null + : props.acceptComment({commentId: comment.id}))} + rejectComment={() => + (comment.status === 'REJECTED' + ? null + : props.rejectComment({commentId: comment.id}))} + /> + ); })}
    +
    - { - flagActions && flagActions.length - ? - : null - } +
    + +
    + {flagActions && flagActions.length + ? + : null}
  • ); }; diff --git a/client/coral-embed-stream/src/actions/config.js b/client/coral-embed-stream/src/actions/config.js new file mode 100644 index 000000000..7e07c4c35 --- /dev/null +++ b/client/coral-embed-stream/src/actions/config.js @@ -0,0 +1,6 @@ +import {ADD_EXTERNAL_CONFIG} from '../constants/config'; + +export const addExternalConfig = config => ({ + type: ADD_EXTERNAL_CONFIG, + config +}); diff --git a/client/coral-embed-stream/src/actions/embed.js b/client/coral-embed-stream/src/actions/embed.js index 863494c68..98505cb99 100644 --- a/client/coral-embed-stream/src/actions/embed.js +++ b/client/coral-embed-stream/src/actions/embed.js @@ -7,4 +7,3 @@ export const setActiveTab = (tab) => (dispatch, getState) => { dispatch(viewAllComments()); } }; - diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/components/Comment.css index 8d7c7ebf9..5fab57d95 100644 --- a/client/coral-embed-stream/src/components/Comment.css +++ b/client/coral-embed-stream/src/components/Comment.css @@ -13,13 +13,87 @@ pointer-events: none; } -.topRightMenu { +.bylineSecondary { + color: #696969; + font-size: 12px; +} + +.editedMarker { + font-style: italic; +} + +/* element in the top right of the Comment */ +.topRight { float: right; + margin-top: 10px; text-align: right; +} + +.topRight > * { + text-align: initial; +} + +.topRight .popover { + margin-top: 1em; + right: 0px; +} + +.topRight .link.active, +.topRight .active .link { + padding-bottom: 0.125em; + border-bottom: 2px solid currentColor; +} + +.topRightMenu { cursor: pointer; margin-top: 5px; } -.topRightMenu > * { - text-align: initial; +.editCommentForm { + margin-bottom: 10px; +} + +.editCommentForm .buttonContainerLeft { + margin-right: auto; + display: flex; + justify-content: center; + flex-direction: column; +} + +.editCommentForm .buttonContainerLeft .editWindowRemaining { + margin-right: 1em; +} + +.editCommentForm .button { + flex-shrink: 0; +} + +.editWindowAlmostOver { + font-weight: bold; +} + +.link { + color: #2376D8; + cursor: pointer; +} + +.popover { + position: absolute; + z-index: 1; +} + +/* Wizard used for Ignore User, Delete Comment confirmations */ +.Wizard { + background-color: #2E343B; + color: white; + padding: 1em; + max-width: 220px; /* consider moving to better class */ +} + +.Wizard header { + font-weight: bold; +} + +.Wizard .textAlignRight { + text-align: right; } diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 1904b4523..a16dc3886 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -19,8 +19,10 @@ import Slot from 'coral-framework/components/Slot'; import LoadMore from './LoadMore'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import {TopRightMenu} from './TopRightMenu'; +import classnames from 'classnames'; +import {EditableCommentContent} from './EditableCommentContent'; import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils'; - +import {getEditableUntilDate} from './util'; import styles from './Comment.css'; const isStaff = tags => !tags.every(t => t.name !== 'STAFF'); @@ -37,7 +39,17 @@ const ActionButton = ({children}) => { class Comment extends React.Component { constructor(props) { super(props); - this.state = {replyBoxVisible: false}; + + // timeout to keep track of Comment edit window expiration + this.editWindowExpiryTimeout = null; + this.onClickEdit = this.onClickEdit.bind(this); + this.stopEditing = this.stopEditing.bind(this); + this.state = { + + // Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment) + isEditing: false, + replyBoxVisible: false, + }; } static propTypes = { @@ -84,7 +96,13 @@ class Comment extends React.Component { user: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired - }).isRequired + }).isRequired, + editing: PropTypes.shape({ + edited: PropTypes.bool, + + // ISO8601 + editableUntil: PropTypes.string, + }) }).isRequired, // given a comment, return whether it should be rendered as ignored @@ -97,10 +115,46 @@ class Comment extends React.Component { removeCommentTag: React.PropTypes.func, // dispatch action to ignore another user - ignoreUser: React.PropTypes.func - }; + ignoreUser: React.PropTypes.func, - render() { + // edit a comment, passed (id, asset_id, { body }) + editComment: React.PropTypes.func, + } + + onClickEdit (e) { + e.preventDefault(); + this.setState({isEditing: true}); + } + + stopEditing () { + if (this._isMounted) { + this.setState({isEditing: false}); + } + } + + componentDidMount() { + this._isMounted = true; + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + + // if still in the edit window, set a timeout to re-render once it expires + const msLeftToEdit = editWindowRemainingMs(this.props.comment); + if (msLeftToEdit > 0) { + this.editWindowExpiryTimeout = setTimeout(() => { + + // re-render + this.setState(this.state); + }, msLeftToEdit); + } + } + componentWillUnmount() { + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + this._isMounted = false; + } + render () { const { comment, parentId, @@ -190,8 +244,17 @@ class Comment extends React.Component { {commentIsBest(comment) ? - : null} - + : null } + + + + { + (comment.editing && comment.editing.edited) + ?  (Edited) + : null + } + + - {currentUser && comment.user.id !== currentUser.id - ? - - - : null} - - + { (currentUser && + (comment.user.id === currentUser.id)) + + /* User can edit/delete their own comment for a short window after posting */ + ? + { + commentIsStillEditable(comment) && + Edit + } + + + /* TopRightMenu allows currentUser to ignore other users' comments */ + : + + + } + + { + this.state.isEditing + ? + :
    + + +
    + } +
    0) { + this.countdownInterval = setInterval(() => { + + // re-render + this.forceUpdate(); + }, 1000); + } + } + componentWillUnmount() { + if (this.countdownInterval) { + this.countdownInterval = clearInterval(this.countdownInterval); + } + } + render() { + const now = new Date(); + const {until, classNameForMsRemaining} = this.props; + const msRemaining = until - now; + const secRemaining = msRemaining / 1000; + const wholeSecRemaining = Math.floor(secRemaining); + const plural = secRemaining !== 1; + const units = lang.t(plural ? 'editComment.secondsPlural' : 'editComment.second'); + let classFromProp; + if (typeof classNameForMsRemaining === 'function') { + classFromProp = classNameForMsRemaining(msRemaining); + } + return ( + + {`${wholeSecRemaining} ${units}`} + + ); + } +} diff --git a/client/coral-embed-stream/src/components/EditableCommentContent.js b/client/coral-embed-stream/src/components/EditableCommentContent.js new file mode 100644 index 000000000..03b61edce --- /dev/null +++ b/client/coral-embed-stream/src/components/EditableCommentContent.js @@ -0,0 +1,153 @@ +import React, {PropTypes} from 'react'; +import {notifyForNewCommentStatus} from 'coral-plugin-commentbox/CommentBox'; +import {CommentForm} from 'coral-plugin-commentbox/CommentForm'; +import styles from './Comment.css'; +import {CountdownSeconds} from './CountdownSeconds'; +import {getEditableUntilDate} from './util'; + +import {Icon} from 'coral-ui'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-framework/translations'; +const lang = new I18n(translations); + +/** + * Renders a Comment's body in such a way that the end-user can edit it and save changes + */ +export class EditableCommentContent extends React.Component { + static propTypes = { + + // show notification to the user (e.g. for errors) + addNotification: PropTypes.func.isRequired, + asset: PropTypes.shape({ + settings: PropTypes.shape({ + charCountEnable: PropTypes.bool, + }), + }).isRequired, + + // comment that is being edited + comment: PropTypes.shape({ + body: PropTypes.string, + editing: PropTypes.shape({ + edited: PropTypes.bool, + + // ISO8601 + editableUntil: PropTypes.string, + }) + }).isRequired, + + // logged in user + currentUser: PropTypes.shape({ + id: PropTypes.string.isRequired + }), + maxCharCount: PropTypes.number, + + // edit a comment, passed {{ body }} + editComment: React.PropTypes.func, + + // called when editing should be stopped + stopEditing: React.PropTypes.func, + } + constructor(props) { + super(props); + this.editComment = this.editComment.bind(this); + this.editWindowExpiryTimeout = null; + } + componentDidMount() { + const editableUntil = getEditableUntilDate(this.props.comment); + const now = new Date(); + const editWindowRemainingMs = editableUntil && (editableUntil - now); + if (editWindowRemainingMs > 0) { + this.editWindowExpiryTimeout = setTimeout(() => { + this.forceUpdate(); + }, editWindowRemainingMs); + } + } + componentWillUnmount() { + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + } + async editComment(edit) { + const {editComment, addNotification, stopEditing} = this.props; + if (typeof editComment !== 'function') {return;} + let response; + let successfullyEdited = false; + try { + response = await editComment(edit); + const errors = (response && response.data && response.data.editComment) + ? response.data.editComment.errors + : null; + if (errors && (errors.length === 1)) { + throw errors[0]; + } + successfullyEdited = true; + } catch (error) { + if (error.translation_key) { + addNotification('error', lang.t(error.translation_key) || error.translation_key); + } else if (error.networkError) { + addNotification('error', lang.t('error.networkError')); + } else { + addNotification('error', lang.t('editComment.unexpectedError')); + throw error; + } + } + if (successfullyEdited) { + const status = response.data.editComment.comment.status; + notifyForNewCommentStatus(this.props.addNotification, status); + } + if (successfullyEdited && typeof stopEditing === 'function') { + stopEditing(); + } + } + render() { + const originalBody = this.props.comment.body; + const editableUntil = getEditableUntilDate(this.props.comment); + const editWindowExpired = (editableUntil - new Date()) < 0; + return ( +
    + { + + // should be disabled if user hasn't actually changed their + // original comment + return (comment.body !== originalBody) && ! editWindowExpired; + }} + saveComment={this.editComment} + bodyLabel={lang.t('editComment.bodyInputLabel')} + bodyPlaceholder="" + submitText={{lang.t('editComment.saveButton')}} + saveButtonCStyle="green" + cancelButtonClicked={this.props.stopEditing} + buttonClass={styles.button} + buttonContainerStart={ +
    + + { + editWindowExpired + ? + {lang.t('editComment.editWindowExpired')} + { + typeof this.props.stopEditing === 'function' + ?  {lang.t('editComment.editWindowExpiredClose')} + : null + } + + : + {lang.t('editComment.editWindowTimerPrefix')} + (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' } + /> + + } + +
    + } + /> +
    + ); + } +} diff --git a/client/coral-embed-stream/src/components/IgnoreUserWizard.css b/client/coral-embed-stream/src/components/IgnoreUserWizard.css deleted file mode 100644 index 838f2f76a..000000000 --- a/client/coral-embed-stream/src/components/IgnoreUserWizard.css +++ /dev/null @@ -1,14 +0,0 @@ -.IgnoreUserWizard { - background-color: #2E343B; - color: white; - padding: 1em; - max-width: 220px; -} - -.IgnoreUserWizard header { - font-weight: bold; -} - -.IgnoreUserWizard .textAlignRight { - text-align: right; -} diff --git a/client/coral-embed-stream/src/components/IgnoreUserWizard.js b/client/coral-embed-stream/src/components/IgnoreUserWizard.js index 371e6d48b..7e72bb924 100644 --- a/client/coral-embed-stream/src/components/IgnoreUserWizard.js +++ b/client/coral-embed-stream/src/components/IgnoreUserWizard.js @@ -1,5 +1,5 @@ import React, {PropTypes} from 'react'; -import styles from './IgnoreUserWizard.css'; +import styles from './Comment.css'; import {Button} from 'coral-ui'; // Guides the user through ignoring another user, including confirming their decision @@ -58,7 +58,7 @@ export class IgnoreUserWizard extends React.Component { const {step} = this.state; const elForThisStep = elsForStep[step - 1]; return ( -
    +
    { elForThisStep }
    ); diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index c5ce3fcf9..b7b325cf6 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -137,6 +137,7 @@ class Stream extends React.Component { comment={highlightedComment} charCountEnable={asset.settings.charCountEnable} maxCharCount={asset.settings.charCount} + editComment={this.props.editComment} /> :
    ) )}
    @@ -205,7 +207,10 @@ Stream.propTypes = { removeCommentTag: PropTypes.func, // dispatch action to ignore another user - ignoreUser: React.PropTypes.func + ignoreUser: React.PropTypes.func, + + // edit a comment, passed (id, asset_id, { body }) + editComment: React.PropTypes.func, }; export default Stream; diff --git a/client/coral-embed-stream/src/components/TopRightMenu.css b/client/coral-embed-stream/src/components/TopRightMenu.css index 5cddae125..0ce8a119b 100644 --- a/client/coral-embed-stream/src/components/TopRightMenu.css +++ b/client/coral-embed-stream/src/components/TopRightMenu.css @@ -20,5 +20,4 @@ position: relative; transform: rotate(180deg); top: 0; - /*top: -0.25em;*/ } diff --git a/client/coral-embed-stream/src/components/util.js b/client/coral-embed-stream/src/components/util.js new file mode 100644 index 000000000..fe5258853 --- /dev/null +++ b/client/coral-embed-stream/src/components/util.js @@ -0,0 +1,10 @@ +/** + * Given a comment, return when the comment can no longer be edited + * @param {Object} comment + * @returns {Date} when the comment can no longer be edited. + */ +export const getEditableUntilDate = (comment) => { + const editing = comment && comment.editing; + const editableUntil = editing && editing.editableUntil && new Date(Date.parse(editing.editableUntil)); + return editableUntil; +}; diff --git a/client/coral-embed-stream/src/constants/config.js b/client/coral-embed-stream/src/constants/config.js new file mode 100644 index 000000000..5821316c5 --- /dev/null +++ b/client/coral-embed-stream/src/constants/config.js @@ -0,0 +1 @@ +export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG'; diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index 6650f8161..72a12c92e 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -41,6 +41,10 @@ export default withFragments({ id } } + editing { + edited + editableUntil + } ${pluginFragments.spreads('comment')} } ${pluginFragments.definitions('comment')} diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 9477ecfb7..995df9001 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -11,21 +11,18 @@ import {authActions, assetActions, pym} from 'coral-framework'; import {getDefinitionName} from 'coral-framework/utils'; import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; -import {setCommentCountCache, viewAllComments} from '../actions/stream'; -import {setActiveTab} from '../actions/embed'; import Stream from './Stream'; +import {setActiveTab} from '../actions/embed'; +import {setCommentCountCache, viewAllComments} from '../actions/stream'; + const {logout, checkLogin} = authActions; const {fetchAssetSuccess} = assetActions; class EmbedContainer extends React.Component { - componentDidMount() { - pym.sendMessage('childReady'); - } - componentWillReceiveProps(nextProps) { - if(this.props.root.me && !nextProps.root.me) { + if (this.props.root.me && !nextProps.root.me) { // Refetch because on logout `excludeIgnored` becomes `false`. // TODO: logout via mutation and obsolete this? @@ -33,7 +30,7 @@ class EmbedContainer extends React.Component { } const {fetchAssetSuccess} = this.props; - if(!isEqual(nextProps.root.asset, this.props.root.asset)) { + if (!isEqual(nextProps.root.asset, this.props.root.asset)) { // TODO: remove asset data from redux store. fetchAssetSuccess(nextProps.root.asset); @@ -48,7 +45,7 @@ class EmbedContainer extends React.Component { } componentDidUpdate(prevProps) { - if(!isEqual(prevProps.root.comment, this.props.root.comment)) { + if (!isEqual(prevProps.root.comment, this.props.root.comment)) { // Scroll to a permalinked comment if one is in the URL once the page is done rendering. setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0); @@ -95,24 +92,25 @@ const mapStateToProps = state => ({ assetId: state.stream.assetId, assetUrl: state.stream.assetUrl, activeTab: state.embed.activeTab, + config: state.config }); const mapDispatchToProps = dispatch => - bindActionCreators({ - fetchAssetSuccess, - checkLogin, - setCommentCountCache, - viewAllComments, - logout, - setActiveTab, - }, dispatch); + bindActionCreators( + { + logout, + checkLogin, + setActiveTab, + viewAllComments, + fetchAssetSuccess, + setCommentCountCache + }, + dispatch + ); export default compose( connect(mapStateToProps, mapDispatchToProps), - branch( - props => !props.auth.checkedInitialLogin, - renderComponent(Spinner), - ), + branch(props => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)), withEmbedQuery, )(EmbedContainer); diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index 47e08fef0..f19cc0a34 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -8,8 +8,9 @@ import isNil from 'lodash/isNil'; import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream'; import { withPostComment, withPostFlag, withPostDontAgree, withDeleteAction, - withAddCommentTag, withRemoveCommentTag, withIgnoreUser, + withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment, } from 'coral-framework/graphql/mutations'; + import {notificationActions, authActions} from 'coral-framework'; import {editName} from 'coral-framework/actions/user'; import {setCommentCountCache, setActiveReplyBox} from '../actions/stream'; @@ -246,5 +247,6 @@ export default compose( withRemoveCommentTag, withIgnoreUser, withDeleteAction, + withEditComment, )(StreamContainer); diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index c9e336941..a123de7e7 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -1,8 +1,19 @@ import {gql} from 'react-apollo'; import {registerConfig} from 'coral-framework/services/registry'; +import update from 'immutability-helper'; const config = { fragments: { + EditCommentResponse: gql` + fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse { + comment { + status + } + errors { + translation_key + } + } + `, StopIgnoringUserResponse: gql` fragment CoralEmbedStream_StopIgnoringUserResponse on StopIgnoringUserResponse { errors { @@ -103,6 +114,10 @@ const config = { created_at } } + editing { + edited + editableUntil + } } `, }, @@ -144,9 +159,9 @@ const config = { } }, updateQueries: { - EmbedQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => { - if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { - return oldData; + EmbedQuery: (previousData, {mutationResult: {data: {createComment: {comment}}}}) => { + if (previousData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { + return previousData; } let updatedAsset; @@ -154,10 +169,10 @@ const config = { // If posting a reply if (parent_id) { updatedAsset = { - ...oldData, + ...previousData, asset: { - ...oldData.asset, - comments: oldData.asset.comments.map((oldComment) => { + ...previousData.asset, + comments: previousData.asset.comments.map((oldComment) => { return oldComment.id === parent_id ? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1} : oldComment; @@ -168,11 +183,11 @@ const config = { // If posting a top-level comment updatedAsset = { - ...oldData, + ...previousData, asset: { - ...oldData.asset, - commentCount: oldData.asset.commentCount + 1, - comments: [comment, ...oldData.asset.comments] + ...previousData.asset, + commentCount: previousData.asset.commentCount + 1, + comments: [comment, ...previousData.asset.comments] } }; } @@ -180,7 +195,75 @@ const config = { return updatedAsset; } } - }) + }), + EditComment: ({ + variables: {id, edit}, + }) => ({ + updateQueries: { + EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment: {status}}}}}) => { + const updateCommentWithEdit = (comment, edit) => { + const {body} = edit; + const editedComment = update(comment, { + $merge: { + body + }, + editing: {$merge:{edited:true}} + }); + return editedComment; + }; + const commentIsStillVisible = (comment) => { + return ! ((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status))); + }; + const resultReflectingEdit = update(previousData, { + asset: { + comments: { + $apply: comments => { + return comments.filter(commentIsStillVisible).map(comment => { + let replyWasEditedToBeHidden = false; + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + const commentWithUpdatedReplies = update(comment, { + replies: { + $apply: (comments) => { + return comments + .filter(c => { + if (commentIsStillVisible(c)) { + return true; + } + replyWasEditedToBeHidden = true; + return false; + }) + .map(comment => { + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + return comment; + }); + } + }, + }); + + // If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented. + if (replyWasEditedToBeHidden) { + return update(commentWithUpdatedReplies, { + replyCount: { + $apply: (replyCount) => { + return replyCount - 1; + } + } + }); + } + return commentWithUpdatedReplies; + }); + } + } + } + }); + return resultReflectingEdit; + }, + }, + }), }, }; diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index bd63ba267..0362f5644 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -5,10 +5,12 @@ import {ApolloProvider} from 'react-apollo'; import {client} from 'coral-framework/services/client'; import {checkLogin} from 'coral-framework/actions/auth'; import './graphql'; +import {addExternalConfig} from 'coral-embed-stream/src/actions/config'; import reducers from './reducers'; import localStore, {injectReducers} from 'coral-framework/services/store'; import AppRouter from './AppRouter'; +import {pym} from 'coral-framework'; injectReducers(reducers); @@ -17,6 +19,12 @@ const store = (window.opener && window.opener.coralStore) ? window.opener.coralS // Don't run this in the popup. if (store === localStore) { store.dispatch(checkLogin()); + + pym.sendMessage('getConfig'); + + pym.onMessage('config', config => { + store.dispatch(addExternalConfig(JSON.parse(config))); + }); } render( diff --git a/client/coral-embed-stream/src/reducers/config.js b/client/coral-embed-stream/src/reducers/config.js new file mode 100644 index 000000000..a602e7f59 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/config.js @@ -0,0 +1,15 @@ +import {ADD_EXTERNAL_CONFIG} from '../constants/config'; + +const initialState = {}; + +export default function config(state = initialState, action) { + switch (action.type) { + case ADD_EXTERNAL_CONFIG: + return { + ...state, + ...action.config + }; + default: + return state; + } +} diff --git a/client/coral-embed-stream/src/reducers/index.js b/client/coral-embed-stream/src/reducers/index.js index a9049fc14..590b87eea 100644 --- a/client/coral-embed-stream/src/reducers/index.js +++ b/client/coral-embed-stream/src/reducers/index.js @@ -1,7 +1,9 @@ -import stream from './stream'; import embed from './embed'; +import config from './config'; +import stream from './stream'; export default { - stream, embed, + stream, + config }; diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index d1afed204..42a0d7c85 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -191,12 +191,13 @@ hr { } .coral-plugin-commentbox-textarea { + color: #262626; flex: 1; - padding: 5px; + padding: 1em; min-height: 100px; margin-top: 10px; font-size: 16px; - border: 1px solid #ccc; + border: 1px solid #9E9E9E; } .coral-plugin-commentbox-button-container { @@ -317,9 +318,7 @@ button.comment__action-button[disabled], } .coral-plugin-pubdate-text { - color: #696969; display: inline-block; - font-size: .75rem; margin-left: 5px; } diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 950fa91bc..570a63aed 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -22,18 +22,18 @@ const snackbarStyles = { transform: 'translate(-50%, 20px)', bottom: 0, boxSizing: 'border-box', - fontFamily: 'Helvetica, \'Helvetica Neue\', Verdana, sans-serif' + fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif' }; // This function should return value of window.Coral const Coral = {}; -const Talk = Coral.Talk = {}; +const Talk = (Coral.Talk = {}); // build the URL to load in the pym iframe function buildStreamIframeUrl(talkBaseUrl, query) { let url = [ talkBaseUrl, - (talkBaseUrl.match(/\/$/) ? '' : '/'), // make sure no double-'/' if opts.talk already ends with '/' + talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/' 'embed/stream?' ].join(''); @@ -44,11 +44,21 @@ function buildStreamIframeUrl(talkBaseUrl, query) { // Set up postMessage listeners/handlers on the pymParent // e.g. to resize the iframe, and navigate the host page -function configurePymParent(pymParent) { +function configurePymParent(pymParent, opts) { let notificationOffset = 200; - let ready = false; let cachedHeight; const snackbar = document.createElement('div'); + + // Sends config to pymChild + function sendConfig(config) { + pymParent.sendMessage('config', JSON.stringify(config)); + } + + // Sends config to the child + pymParent.onMessage('getConfig', function() { + sendConfig(opts || {}); + }); + snackbar.id = 'coral-notif'; for (let key in snackbarStyles) { @@ -69,12 +79,12 @@ function configurePymParent(pymParent) { } }); - pymParent.onMessage('coral-clear-notification', function () { + pymParent.onMessage('coral-clear-notification', function() { snackbar.style.opacity = 0; }); // remove the permalink comment id from the hash - pymParent.onMessage('coral-view-all-comments', function () { + pymParent.onMessage('coral-view-all-comments', function() { window.history.replaceState( {}, document.title, @@ -82,7 +92,7 @@ function configurePymParent(pymParent) { ); }); - pymParent.onMessage('coral-alert', function (message) { + pymParent.onMessage('coral-alert', function(message) { const [type, text] = message.split('|'); snackbar.style.transform = 'translate(-50%, 20px)'; snackbar.style.opacity = 0; @@ -110,39 +120,21 @@ function configurePymParent(pymParent) { 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 - pymParent.sendMessage('DOMContentLoaded'); - } - }, 100); - }); - // When end-user clicks link in iframe, open it in parent context - pymParent.onMessage('navigate', function (url) { + 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 ) ){ + if (!('innerWidth' in window)) { a = 'client'; e = document.documentElement || document.body; } return { - width : e[`${a}Width`], - height : e[`${a}Height`] + width: e[`${a}Width`], + height: e[`${a}Height`] }; }; } @@ -156,18 +148,24 @@ function configurePymParent(pymParent) { * @param {String} [opts.asset_url] - Asset URL * @param {String} [opts.asset_id] - Asset ID */ -Talk.render = function (el, opts) { +Talk.render = function(el, opts) { if (!el) { - throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'); + 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})`); + 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'); + 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. @@ -186,16 +184,21 @@ Talk.render = function (el, opts) { try { query.asset_url = document.querySelector('link[rel="canonical"]').href; } catch (e) { - console.warn('This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'); + console.warn( + 'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.' + ); query.asset_url = window.location.origin + window.location.pathname; } } - configurePymParent(new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), { - title: opts.title, - id: `${el.id}_iframe`, - name: `${el.id}_iframe` - })); + configurePymParent( + new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), { + title: opts.title, + id: `${el.id}_iframe`, + name: `${el.id}_iframe` + }), + opts + ); }; export default Coral; diff --git a/client/coral-framework/components/Slot.css b/client/coral-framework/components/Slot.css index 6048da181..6a95d79ad 100644 --- a/client/coral-framework/components/Slot.css +++ b/client/coral-framework/components/Slot.css @@ -1,3 +1,7 @@ .inline { display: inline-block; } + +.debug { + background-color: coral; +} \ No newline at end of file diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index 8e2dd9502..939068e83 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -1,12 +1,13 @@ import React from 'react'; import cn from 'classnames'; import styles from './Slot.css'; +import {connect} from 'react-redux'; import {getSlotElements} from 'coral-framework/helpers/plugins'; -export default function Slot ({fill, inline = false, ...rest}) { +function Slot ({fill, inline = false, plugin_config: config, ...rest}) { return ( -
    - {getSlotElements(fill, rest)} +
    + {getSlotElements(fill, {...rest, config})}
    ); } @@ -14,3 +15,8 @@ export default function Slot ({fill, inline = false, ...rest}) { Slot.propTypes = { fill: React.PropTypes.string }; + +const mapStateToProps = ({config: {plugin_config = {}}}) => ({plugin_config}); + +export default connect(mapStateToProps, null)(Slot); + diff --git a/client/coral-framework/constants/asset.js b/client/coral-framework/constants/asset.js index c26dd099d..40f746706 100644 --- a/client/coral-framework/constants/asset.js +++ b/client/coral-framework/constants/asset.js @@ -8,4 +8,3 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE'; export const OPEN_COMMENTS = 'OPEN_COMMENTS'; export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; - diff --git a/client/coral-framework/constants/config.js b/client/coral-framework/constants/config.js deleted file mode 100644 index 5dca44ba9..000000000 --- a/client/coral-framework/constants/config.js +++ /dev/null @@ -1,9 +0,0 @@ -export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST'; -export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS'; -export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE'; - -export const UPDATE_CONFIG = 'UPDATE_CONFIG'; - -export const OPEN_COMMENTS = 'OPEN_COMMENTS'; -export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; -export const ADD_ITEM = 'ADD_ITEM'; diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index 1f1179b58..66e68be97 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -20,6 +20,27 @@ export const withPostComment = withMutation( }), }); +export const withEditComment = withMutation( + gql` + mutation EditComment($id: ID!, $asset_id: ID!, $edit: EditCommentInput) { + editComment(id:$id, asset_id:$asset_id, edit:$edit) { + ...EditCommentResponse + } + } + `, { + props: ({mutate}) => ({ + editComment: (id, asset_id, edit) => { + return mutate({ + variables: { + id, + asset_id, + edit, + }, + }); + } + }), + }); + export const withPostFlag = withMutation( gql` mutation PostFlag($flag: CreateFlagInput!) { diff --git a/client/coral-framework/helpers/hoc.js b/client/coral-framework/helpers/hoc.js new file mode 100644 index 000000000..bb034b179 --- /dev/null +++ b/client/coral-framework/helpers/hoc.js @@ -0,0 +1,3 @@ +export function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index bcf6e4c50..e55715e4b 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -21,7 +21,7 @@ export function getSlotElements(slot, props = {}) { .filter(o => o.module.slots[slot]) .map(o => o.module.slots[slot])); return components - .map((component, i) => React.createElement(component, {...props, key: i})); + .map((component, i) => React.createElement(component, {key: i, ...props})); } function getComponentFragments(components) { diff --git a/client/coral-framework/helpers/strings.js b/client/coral-framework/helpers/strings.js new file mode 100644 index 000000000..0a267da59 --- /dev/null +++ b/client/coral-framework/helpers/strings.js @@ -0,0 +1,4 @@ +export function capitalize(str) { + const newString = new String(str); + return newString.charAt(0).toUpperCase() + newString.slice(1); +} diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index d624a3308..ef4c2ac74 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -1,3 +1,5 @@ export {default as withFragments} from './withFragments'; export {default as withMutation} from './withMutation'; export {default as withQuery} from './withQuery'; +export {default as withReaction} from './withReaction'; + diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index bcc362ca3..a5060f27a 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -1,8 +1,7 @@ import React from 'react'; -import {getDisplayName} from 'recompose'; +import {getDisplayName} from '../helpers/hoc'; // TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38. - export default fragments => WrappedComponent => { class WithFragments extends React.Component { render() { diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index 844d4ef49..59a4fb627 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -3,6 +3,7 @@ import {graphql} from 'react-apollo'; import merge from 'lodash/merge'; import uniq from 'lodash/uniq'; import flatten from 'lodash/flatten'; +import isEmpty from 'lodash/isEmpty'; import {getMutationOptions, resolveFragments} from 'coral-framework/services/registry'; import {store} from 'coral-framework/services/store'; import {getDefinitionName} from '../utils'; @@ -71,6 +72,9 @@ export default (document, config) => WrappedComponent => { updateQueries, update, }; + if (isEmpty(wrappedConfig.optimisticResponse)) { + delete wrappedConfig.optimisticResponse; + } return data.mutate(wrappedConfig); }; return config.props({...data, mutate}); diff --git a/client/coral-framework/hocs/withReaction.js b/client/coral-framework/hocs/withReaction.js new file mode 100644 index 000000000..c383dbed8 --- /dev/null +++ b/client/coral-framework/hocs/withReaction.js @@ -0,0 +1,243 @@ +import React from 'react'; +import get from 'lodash/get'; +import uuid from 'uuid/v4'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getDisplayName} from '../helpers/hoc'; +import {compose, gql, graphql} from 'react-apollo'; +import withFragments from 'coral-framework/hocs/withFragments'; +import {showSignInDialog} from 'coral-framework/actions/auth'; +import {capitalize} from 'coral-framework/helpers/strings'; +import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; + +export default reaction => WrappedComponent => { + if (typeof reaction !== 'string') { + console.error('Reaction must be a valid string'); + return null; + } + + reaction = reaction.toLowerCase(); + + class WithReactions extends React.Component { + render() { + const {comment} = this.props; + + const reactionSummary = getMyActionSummary( + `${capitalize(reaction)}ActionSummary`, + comment + ); + + const count = getTotalActionCount( + `${capitalize(reaction)}ActionSummary`, + comment + ); + + const alreadyReacted = () => !!reactionSummary; + + const withReactionProps = {reactionSummary, count, alreadyReacted}; + + return ; + } + } + + const isReaction = a => + a.__typename === `${capitalize(reaction)}ActionSummary`; + + const COMMENT_FRAGMENT = gql` + fragment ${capitalize(reaction)}Button_updateFragment on Comment { + action_summaries { + ... on ${capitalize(reaction)}ActionSummary { + count + current_user { + id + } + } + } + } + `; + + const withDeleteReaction = graphql( + gql` + mutation deleteReaction($id: ID!) { + deleteAction(id:$id) { + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + deleteReaction: () => { + + const reactionSummary = getMyActionSummary( + `${capitalize(reaction)}ActionSummary`, + ownProps.comment + ); + + const reactionData = { + id: reactionSummary.current_user.id, + commentId: ownProps.comment.id + }; + + return mutate({ + variables: {id: reactionData.id}, + optimisticResponse: { + deleteAction: { + __typename: 'DeleteActionResponse', + errors: null + } + }, + update: proxy => { + const fragmentId = `Comment_${reactionData.commentId}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Check whether we liked this comment. + const idx = data.action_summaries.findIndex(isReaction); + if ( + idx < 0 || + get(data.action_summaries[idx], 'current_user.id') !== reactionData.id + ) { + return; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count - 1, + current_user: null + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + } + }); + } + }) + } + ); + + const withPostReaction = graphql( + gql` + mutation create${capitalize(reaction)}($${reaction}: Create${capitalize(reaction)}Input!) { + create${capitalize(reaction)}(${reaction}: $${reaction}) { + ${reaction} { + id + } + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + postReaction: () => { + + const reactionData = { + item_id: ownProps.comment.id, + item_type: 'COMMENTS' + }; + + return mutate({ + variables: {[reaction]: reactionData}, + optimisticResponse: { + [`create${capitalize(reaction)}`]: { + __typename: `Create${capitalize(reaction)}Response`, + errors: null, + [reaction]: { + __typename: `${capitalize(reaction)}Action`, + id: uuid() + } + } + }, + update: (proxy, mutationResult) => { + const fragmentId = `Comment_${reactionData.item_id}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Add our comment from the mutation to the end. + let idx = data.action_summaries.findIndex(isReaction); + + // Check whether we already reactioned this comment. + if (idx >= 0 && data.action_summaries[idx].current_user) { + return; + } + + if (idx < 0) { + + // Add initial action when it doesn't exist. + data.action_summaries.push({ + __typename: `${capitalize(reaction)}ActionSummary`, + count: 0, + current_user: null + }); + idx = data.action_summaries.length - 1; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count + 1, + current_user: mutationResult.data[ + `create${capitalize(reaction)}` + ][reaction] + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + } + }); + } + }) + } + ); + + const mapDispatchToProps = dispatch => + bindActionCreators({showSignInDialog}, dispatch); + + const enhance = compose( + withFragments({ + root: gql` + fragment ${capitalize(reaction)}Button_root on RootQuery { + me { + status + } + } + `, + comment: gql` + fragment ${capitalize(reaction)}Button_comment on Comment { + action_summaries { + ... on ${capitalize(reaction)}ActionSummary { + count + current_user { + id + } + } + } + }` + }), + connect(null, mapDispatchToProps), + withDeleteReaction, + withPostReaction + ); + + WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`; + + return enhance(WithReactions); +}; diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json index 88ead57af..40860fc3a 100644 --- a/client/coral-framework/translations.json +++ b/client/coral-framework/translations.json @@ -22,9 +22,21 @@ "comment": "comment", "comments": "comments", "commentIsIgnored": "This comment is hidden because you ignored this user.", + "editComment": { + "bodyInputLabel": "Edit this comment", + "saveButton": "Save changes", + "editWindowExpired": "You can no longer edit this comment. The time window to do so has expired. Why not post another one?", + "editWindowExpiredClose": "Close", + "editWindowTimerPrefix": "Edit Window: ", + "second": "second", + "secondsPlural": "seconds", + "unexpectedError": "Unexpected error while saving changes. Sorry!" + }, "error": { + "editWindowExpired": "You can no longer edit this comment. The time window to do so has expired.", "emailNotVerified": "Email address {0} not verified.", "email": "Not a valid E-Mail", + "networkError": "Failed to connect to server. Check your internet connection and try again.", "password": "Password must be at least 8 characters", "username": "Usernames can contain letters, numbers and _ only", "confirmPassword": "Passwords don't match. Please, check again", @@ -61,9 +73,21 @@ "comments": "commentarios", "commentIsIgnored": "Este comentario está escondido porque has ignorado al usuario.", "showAllComments": "Mostrar todos los comentarios", + "editComment": { + "bodyInputLabel": "Editar este comentario", + "saveButton": "Guardar cambios", + "editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado. ¿Por qué no publicar otro?", + "editWindowExpiredClose": "Cerca", + "editWindowTimerPrefix": "Ventana de edición: ", + "second": "segundo", + "secondsPlural": "segundos", + "unexpectedError": "Unexpected error while saving changes. Sorry!" + }, "error": { + "editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado.", "emailNotVerified": "E-mail {0} no verificado.", "email": "No es un e-mail válido", + "networkError": "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo.", "password": "La contraseña debe tener por lo menos 8 caracteres", "username": "Los nombres pueden contener letras, números y _", "organizationName": "El nombre de la organización debe contener letras y/o números.", diff --git a/client/coral-plugin-api/index.js b/client/coral-plugin-api/index.js new file mode 100644 index 000000000..f0f5ad9cc --- /dev/null +++ b/client/coral-plugin-api/index.js @@ -0,0 +1 @@ +export {withReaction} from '../coral-framework/hocs'; diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index e19224179..34c08cc57 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -1,28 +1,46 @@ import React, {PropTypes} from 'react'; -import {Button} from 'coral-ui'; -import {connect} from 'react-redux'; import {I18n} from '../coral-framework'; import translations from './translations.json'; import Slot from 'coral-framework/components/Slot'; +import {connect} from 'react-redux'; +import {CommentForm} from './CommentForm'; -const name = 'coral-plugin-commentbox'; +export const name = 'coral-plugin-commentbox'; +// Given a newly posted comment's status, show a notification to the user +// if needed +export const notifyForNewCommentStatus = (addNotification, status) => { + if (status === 'REJECTED') { + addNotification('error', lang.t('comment-post-banned-word')); + } else if (status === 'PREMOD') { + addNotification('success', lang.t('comment-post-notif-premod')); + } +}; + +/** + * Container for posting a new Comment + */ class CommentBox extends React.Component { - constructor(props) { super(props); this.state = { username: '', - body: '', + + // incremented on successful post to clear form + postedCount: 0, hooks: { preSubmit: [], postSubmit: [] } }; } - - postComment = () => { + static get defaultProps() { + return { + setCommentCountCache: () => {} + }; + } + postComment = ({body}) => { const { commentPostedHandler, postComment, @@ -37,7 +55,7 @@ class CommentBox extends React.Component { let comment = { asset_id: assetId, parent_id: parentId, - body: this.state.body, + body, ...this.props.commentBox }; @@ -53,11 +71,11 @@ class CommentBox extends React.Component { // Execute postSubmit Hooks this.state.hooks.postSubmit.forEach(hook => hook(data)); + notifyForNewCommentStatus(addNotification, postedComment.status); + if (postedComment.status === 'REJECTED') { - addNotification('error', lang.t('comment-post-banned-word')); !isReply && setCommentCountCache(commentCountCache); } else if (postedComment.status === 'PREMOD') { - addNotification('success', lang.t('comment-post-notif-premod')); !isReply && setCommentCountCache(commentCountCache); } @@ -69,7 +87,8 @@ class CommentBox extends React.Component { console.error(err); !isReply && setCommentCountCache(commentCountCache); }); - this.setState({body: ''}); + + this.setState({postedCount: this.state.postedCount + 1}); } registerHook = (hookType = '', hook = () => {}) => { @@ -126,69 +145,39 @@ class CommentBox extends React.Component { const {styles, isReply, authorId, maxCharCount} = this.props; let {cancelButtonClicked} = this.props; - const length = this.state.body.length; - const enablePostComment = !length || (maxCharCount && length > maxCharCount); - if (isReply && typeof cancelButtonClicked !== 'function') { console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply'); cancelButtonClicked = () => {}; } return
    -
    - -