diff --git a/.eslintignore b/.eslintignore index 8a8c3a213..3a259c106 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,4 +12,5 @@ plugins/* !plugins/coral-plugin-love !plugins/coral-plugin-viewing-options !plugins/coral-plugin-comment-content +!plugins/talk-plugin-permalink node_modules diff --git a/.gitignore b/.gitignore index b3e0eaa73..0be1266c6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ plugins/* !plugins/coral-plugin-love !plugins/coral-plugin-viewing-options !plugins/coral-plugin-comment-content +!plugins/talk-plugin-permalink **/node_modules/* diff --git a/client/coral-admin/src/components/TagsInput.css b/client/coral-admin/src/components/TagsInput.css new file mode 100644 index 000000000..6bb630521 --- /dev/null +++ b/client/coral-admin/src/components/TagsInput.css @@ -0,0 +1,51 @@ +.root { + background-color: #fff; + border: 1px solid #ccc; + overflow: hidden; + padding-left: 5px; + padding-top: 5px; + +} + +.rootFocuesd { + border-color: rgb(142, 76, 65); +} + +.tag { + background-color: rgb(255, 220, 214); + border-radius: 2px; + border: 1px solid rgb(244, 126, 107); + color: rgb(244, 126, 107); + display: inline-block; + font-family: sans-serif; + font-size: 13px; + font-weight: 400; + margin-bottom: 5px; + margin-right: 5px; + padding: 5px; +} + +.tagRemove { + cursor: pointer; + font-weight: bold; + color: rgb(101, 24, 23); + + &::before { + content: " ×"; + } +} + +.input > input { + background: transparent; + border: 0; + color: #777; + font-family: sans-serif; + font-size: 13px; + font-weight: 400; + margin-bottom: 6px; + margin-top: 1px; + outline: none; + padding: 5px; +} + + diff --git a/client/coral-admin/src/components/TagsInput.js b/client/coral-admin/src/components/TagsInput.js new file mode 100644 index 000000000..d9854ed86 --- /dev/null +++ b/client/coral-admin/src/components/TagsInput.js @@ -0,0 +1,31 @@ +import React from 'react'; +import TagsInput from 'react-tagsinput'; +import styles from './TagsInput.css'; +import AutosizeInput from 'react-input-autosize'; + +const autosizingRenderInput = ({onChange, value, addTag: _, ...other}) => + ; + +export default (props) => { + return ( + data.split(',').map((d) => d.trim())} + className={styles.root} + focusedClassName={styles.rootFocus} + renderInput={autosizingRenderInput} + {...props} + + tagProps={{ + className: styles.tag, + classNameRemove: styles.tagRemove, + ...props.tagProps, + }} + inputProps={{ + className: styles.input, + ...props.inputProps, + }} + /> + ); +}; diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 2f2e75275..29df3753c 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -1,6 +1,7 @@ import React from 'react'; import {render} from 'react-dom'; import {ApolloProvider} from 'react-apollo'; +import smoothscroll from 'smoothscroll-polyfill'; import {getClient} from './services/client'; import store from './services/store'; @@ -13,6 +14,7 @@ import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/he loadPluginsTranslations(); injectPluginsReducers(); +smoothscroll.polyfill(); render( diff --git a/client/coral-admin/src/routes/Community/components/User.js b/client/coral-admin/src/routes/Community/components/User.js index ee917e295..b6a01f016 100644 --- a/client/coral-admin/src/routes/Community/components/User.js +++ b/client/coral-admin/src/routes/Community/components/User.js @@ -2,17 +2,18 @@ import React from 'react'; import styles from './Community.css'; import ActionButton from './ActionButton'; +import {username} from 'coral-plugin-flags/helpers/flagReasons'; import ActionsMenu from 'coral-admin/src/components/ActionsMenu'; import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem'; import t from 'coral-framework/services/i18n'; const shortReasons = { - 'This comment is offensive': t('community.offensive'), - 'This looks like an ad/marketing': t('community.spam_ads'), - 'This user is impersonating': t('community.impersonating'), - 'I don\'t like this username': t('community.dont_like_username'), - 'Other': t('community.other') + [username.other]: t('community.other'), + [username.spam]: t('community.spam_ads'), + [username.offensive]: t('community.offensive'), + [username.nolike]: t('community.dont_like_username'), + [username.impersonating]: t('community.impersonating'), }; // Render a single user for the list diff --git a/client/coral-admin/src/routes/Configure/components/Configure.css b/client/coral-admin/src/routes/Configure/components/Configure.css index 22e46518c..5b02b963a 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.css +++ b/client/coral-admin/src/routes/Configure/components/Configure.css @@ -136,13 +136,6 @@ resize: none; } -#bannedWordlist, #suspectWordlist { - display: block; - input { - width: 100%; - } -} - .customCSSInput { width: 100%; font-size: 14px; diff --git a/client/coral-admin/src/routes/Configure/components/Domainlist.js b/client/coral-admin/src/routes/Configure/components/Domainlist.js index 45790ec89..7b7565c5e 100644 --- a/client/coral-admin/src/routes/Configure/components/Domainlist.js +++ b/client/coral-admin/src/routes/Configure/components/Domainlist.js @@ -1,7 +1,7 @@ import React from 'react'; import {Card} from 'coral-ui'; import styles from './Configure.css'; -import TagsInput from 'react-tagsinput'; +import TagsInput from 'coral-admin/src/components/TagsInput'; import t from 'coral-framework/services/i18n'; const Domainlist = ({domains, onChangeDomainlist}) => { @@ -14,8 +14,6 @@ const Domainlist = ({domains, onChangeDomainlist}) => { data.split(',').map((d) => d.trim())} onChange={(tags) => onChangeDomainlist('whitelist', tags)} /> diff --git a/client/coral-admin/src/routes/Configure/components/Wordlist.js b/client/coral-admin/src/routes/Configure/components/Wordlist.js index af8c1b383..e96f37f9f 100644 --- a/client/coral-admin/src/routes/Configure/components/Wordlist.js +++ b/client/coral-admin/src/routes/Configure/components/Wordlist.js @@ -1,6 +1,6 @@ import React from 'react'; import t from 'coral-framework/services/i18n'; -import TagsInput from 'react-tagsinput'; +import TagsInput from 'coral-admin/src/components/TagsInput'; import styles from './Configure.css'; import {Card} from 'coral-ui'; @@ -13,8 +13,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => ( data.split(',').map((d) => d.trim())} onChange={(tags) => onChangeWordlist('banned', tags)} /> @@ -26,8 +24,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => ( data.split(',').map((d) => d.trim())} onChange={(tags) => onChangeWordlist('suspect', tags)} /> diff --git a/client/coral-admin/src/routes/Install/components/Steps/PermittedDomainsStep.js b/client/coral-admin/src/routes/Install/components/Steps/PermittedDomainsStep.js index 6741954ec..20b037890 100644 --- a/client/coral-admin/src/routes/Install/components/Steps/PermittedDomainsStep.js +++ b/client/coral-admin/src/routes/Install/components/Steps/PermittedDomainsStep.js @@ -1,7 +1,7 @@ import React from 'react'; import styles from './style.css'; import {Button, Card} from 'coral-ui'; -import TagsInput from 'react-tagsinput'; +import TagsInput from 'coral-admin/src/components/TagsInput'; import t from 'coral-framework/services/i18n'; @@ -16,9 +16,6 @@ const PermittedDomainsStep = (props) => { data.split(',').map((d) => d.trim())} onChange={(tags) => handleDomainsChange(tags)} /> diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 3c5e535b2..26543be6e 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -168,7 +168,7 @@ class Comment extends React.Component { {' '} {t('comment.view_context')} diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 754f44558..2e854633b 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -1,11 +1,10 @@ import React, {PropTypes} from 'react'; -import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton'; import AuthorName from 'coral-plugin-author-name/AuthorName'; import TagLabel from 'coral-plugin-tag-label/TagLabel'; import PubDate from 'coral-plugin-pubdate/PubDate'; import {ReplyBox, ReplyButton} from 'coral-plugin-replies'; -import FlagComment from 'coral-plugin-flags/FlagComment'; +import {FlagComment} from 'coral-plugin-flags'; import {can} from 'coral-framework/services/perms'; import {TransitionGroup} from 'react-transition-group'; import cn from 'classnames'; @@ -25,7 +24,7 @@ import CommentContent from './CommentContent'; import Slot from 'coral-framework/components/Slot'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import {EditableCommentContent} from './EditableCommentContent'; -import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils'; +import {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils'; import t from 'coral-framework/services/i18n'; const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF'); @@ -76,7 +75,6 @@ const ActionButton = ({children}) => { }; export default class Comment extends React.Component { - isLoadingReplies = false; constructor(props) { super(props); @@ -91,6 +89,7 @@ export default class Comment extends React.Component { isEditing: false, replyBoxVisible: false, animateEnter: false, + loadingState: '', ...resetCursors({}, props), }; } @@ -170,7 +169,7 @@ export default class Comment extends React.Component { replies: PropTypes.object, user: PropTypes.shape({ id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired + username: PropTypes.string.isRequired }).isRequired, editing: PropTypes.shape({ edited: PropTypes.bool, @@ -196,8 +195,16 @@ export default class Comment extends React.Component { editComment: React.PropTypes.func, } + editComment = (...args) => { + return this.props.editComment(this.props.comment.id, this.props.asset.id, ...args); + } + onClickEdit (e) { e.preventDefault(); + if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { + this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + return; + } this.setState({isEditing: true}); } @@ -213,24 +220,23 @@ export default class Comment extends React.Component { } loadNewReplies = () => { - if (!this.isLoadingReplies) { - this.isLoadingReplies = true; - const {replies, replyCount, id} = this.props.comment; - if (replyCount > replies.nodes.length) { - this.props.loadMore(id) - .then(() => { - this.setState(resetCursors(this.state, this.props)); - this.isLoadingReplies = false; - }) - .catch((e) => { - this.isLoadingReplies = false; - throw e; + const {replies, replyCount, id} = this.props.comment; + if (replyCount > replies.nodes.length) { + this.setState({loadingState: 'loading'}); + this.props.loadMore(id) + .then(() => { + this.setState({ + ...resetCursors(this.state, this.props), + loadingState: 'success', }); - return; - } - this.setState(resetCursors); - this.isLoadingReplies = false; + }) + .catch((error) => { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + }); + return; } + this.setState(resetCursors); }; showReplyBox = () => { @@ -240,7 +246,8 @@ export default class Comment extends React.Component { } if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { this.props.setActiveReplyBox(this.props.comment.id); - return; + } else { + this.props.addNotification('error', t('error.NOT_AUTHORIZED')); } return; } @@ -323,6 +330,7 @@ export default class Comment extends React.Component { } = this.props; const view = this.getVisibileReplies(); + const {loadingState} = this.state; const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length); const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount; @@ -469,7 +477,7 @@ export default class Comment extends React.Component { { this.state.isEditing ? } + +
-
-
- - - : null} @@ -587,12 +595,13 @@ export default class Comment extends React.Component { />; })} -
+
diff --git a/client/coral-embed-stream/src/components/EditableCommentContent.js b/client/coral-embed-stream/src/components/EditableCommentContent.js index 1373707a0..7e6035d58 100644 --- a/client/coral-embed-stream/src/components/EditableCommentContent.js +++ b/client/coral-embed-stream/src/components/EditableCommentContent.js @@ -4,6 +4,8 @@ import {CommentForm} from 'coral-plugin-commentbox/CommentForm'; import styles from './Comment.css'; import {CountdownSeconds} from './CountdownSeconds'; import {getEditableUntilDate} from './util'; +import {can} from 'coral-framework/services/perms'; +import {forEachError} from 'coral-framework/utils'; import {Icon} from 'coral-ui'; import t from 'coral-framework/services/i18n'; @@ -45,10 +47,14 @@ export class EditableCommentContent extends React.Component { // called when editing should be stopped stopEditing: React.PropTypes.func, } + constructor(props) { super(props); - this.editComment = this.editComment.bind(this); this.editWindowExpiryTimeout = null; + this.state = { + body: props.comment.body, + loadingState: '', + }; } componentDidMount() { const editableUntil = getEditableUntilDate(this.props.comment); @@ -65,69 +71,75 @@ export class EditableCommentContent extends React.Component { this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); } } - async editComment(edit) { + + handleBodyChange = (body) => { + this.setState({body}); + } + + handleSubmit = async () => { + if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { + this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + return; + } + + this.setState({loadingState: 'loading'}); + 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) { - const errors = error.errors || [error]; - errors.forEach((e) => { - if (e.translation_key) { - addNotification('error', t(`error.${e.translation_key}`)); - } else if (error.networkError) { - addNotification('error', t('error.network_error')); - } else { - addNotification('error', t('edit_comment.unexpected_error')); - console.error(e); - } - }); - } - if (successfullyEdited) { + response = await editComment({body: this.state.body}); + this.setState({loadingState: 'success'}); const status = response.data.editComment.comment.status; notifyForNewCommentStatus(this.props.addNotification, status); - } - if (successfullyEdited && typeof stopEditing === 'function') { - stopEditing(); + if (typeof stopEditing === 'function') { + stopEditing(); + } + } catch (error) { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => addNotification('error', msg)); } } + + getEditableUntil = (props = this.props) => { + return getEditableUntilDate(props.comment); + } + + isEditWindowExpired = (props = this.props) => { + return (this.getEditableUntil(props) - new Date()) < 0; + } + + isSubmitEnabled = (comment) => { + + // should be disabled if user hasn't actually changed their + // original comment + return (comment.body !== this.props.comment.body) && !this.isEditWindowExpired(); + } + 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} + submitEnabled={this.isSubmitEnabled} + body={this.state.body} + onBodyChange={this.handleBodyChange} + onSubmit={this.handleSubmit} bodyLabel={t('edit_comment.body_input_label')} bodyPlaceholder="" submitText={{t('edit_comment.save_button')}} - saveButtonCStyle="green" - cancelButtonClicked={this.props.stopEditing} - buttonClass={styles.button} + submitButtonCStyle="green" + onCancel={this.props.stopEditing} + submitButtonClassName={styles.button} + cancelButtonClassName={styles.button} + loadingState={this.state.loadingState} buttonContainerStart={
{ - editWindowExpired + this.isEditWindowExpired() ? {t('edit_comment.edit_window_expired')} { @@ -139,7 +151,7 @@ export class EditableCommentContent extends React.Component { : {t('edit_comment.edit_window_timer_prefix')} (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' } /> diff --git a/client/coral-embed-stream/src/components/IgnoreUserWizard.js b/client/coral-embed-stream/src/components/IgnoreUserWizard.js index 7e72bb924..b7612b9f8 100644 --- a/client/coral-embed-stream/src/components/IgnoreUserWizard.js +++ b/client/coral-embed-stream/src/components/IgnoreUserWizard.js @@ -9,7 +9,7 @@ export class IgnoreUserWizard extends React.Component { // comment on which this menu appears user: PropTypes.shape({ id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired + username: PropTypes.string.isRequired }).isRequired, cancel: PropTypes.func.isRequired, @@ -47,7 +47,7 @@ export class IgnoreUserWizard extends React.Component { const step2Confirmation = (
Ignore User
-

Are you sure you want to ignore { user.name }?

+

Are you sure you want to ignore { user.username }?

diff --git a/client/coral-embed-stream/src/components/LoadMore.js b/client/coral-embed-stream/src/components/LoadMore.js index 3d1ad4679..a2f3d7824 100644 --- a/client/coral-embed-stream/src/components/LoadMore.js +++ b/client/coral-embed-stream/src/components/LoadMore.js @@ -1,12 +1,11 @@ -import React, {PropTypes} from 'react'; +import React from 'react'; +import PropTypes from 'prop-types'; import {Button} from 'coral-ui'; import t from 'coral-framework/services/i18n'; +import cn from 'classnames'; class LoadMore extends React.Component { - - componentDidMount () { - this.initialState = true; - } + initialState = true; replyCountFormat = (count) => { if (!count) { @@ -23,17 +22,22 @@ class LoadMore extends React.Component { } } - loadMore = () => { - this.initialState = false; - this.props.loadMore(); + componentWillReceiveProps(nextProps) { + if (['success', 'error'].indexOf(nextProps.loadingState) >= 0) { + this.initialState = false; + } } render () { - const {topLevel, moreComments, replyCount} = this.props; + const {topLevel, moreComments, replyCount, loadingState, loadMore} = this.props; + const disabled = loadingState === 'loading'; return moreComments - ?
+ ?
@@ -44,7 +48,9 @@ class LoadMore extends React.Component { LoadMore.propTypes = { replyCount: PropTypes.number, topLevel: PropTypes.bool.isRequired, - loadMore: PropTypes.func.isRequired + loadMore: PropTypes.func.isRequired, + moreComments: PropTypes.bool, + loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']), }; export default LoadMore; diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index c912d1a72..0cfaf0368 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -1,5 +1,6 @@ import React, {PropTypes} from 'react'; import LoadMore from './LoadMore'; +import {StreamError} from './StreamError'; import Comment from '../components/Comment'; import SuspendedAccount from './SuspendedAccount'; import Slot from 'coral-framework/components/Slot'; @@ -14,6 +15,7 @@ import QuestionBox from 'coral-plugin-questionbox/QuestionBox'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import NewCount from './NewCount'; import {TransitionGroup} from 'react-transition-group'; +import {forEachError} from 'coral-framework/utils'; const hasComment = (nodes, id) => nodes.some((node) => node.id === id); @@ -53,11 +55,13 @@ function invalidateCursor(invalidated, state, props) { class Stream extends React.Component { - isLoadingMore = false; - constructor(props) { super(props); - this.state = resetCursors(this.state, props); + this.state = { + ...resetCursors(this.state, props), + keepCommentBox: false, + loadingState: '', + }; } componentWillReceiveProps(next) { @@ -68,6 +72,12 @@ class Stream extends React.Component { this.setState(resetCursors); return; } + + // Keep comment box when user was live suspended, banned, ... + if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) { + this.setState({keepCommentBox: true}); + } + if ( prevComments && nextComments && nextComments.nodes.length < prevComments.nodes.length @@ -98,15 +108,15 @@ class Stream extends React.Component { }; loadMoreComments = () => { - if (!this.isLoadingMore) { - this.isLoadingMore = true; - this.props.loadMoreComments() - .then(() => this.isLoadingMore = false) - .catch((e) => { - this.isLoadingMore = false; - throw e; - }); - } + this.setState({loadingState: 'loading'}); + this.props.loadMoreComments() + .then(() => { + this.setState({loadingState: 'success'}); + }) + .catch((error) => { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + }); } // getVisibileComments returns a list containing comments @@ -133,6 +143,10 @@ class Stream extends React.Component { return view; } + userIsDegraged({auth: {user}} = this.props) { + return !can(user, 'INTERACT_WITH_COMMUNITY'); + } + render() { const { commentClassNames, @@ -150,6 +164,7 @@ class Stream extends React.Component { pluginProps, editName } = this.props; + const {keepCommentBox, loadingState} = this.state; const view = this.getVisibleComments(); const open = asset.closedAt === null; @@ -171,6 +186,14 @@ class Stream extends React.Component { me.ignoredUsers.find((u) => u.id === comment.user.id) ); }; + + const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox); + + if (!comment && !comments) { + console.error('Talk: No comments came back from the graph given that query. Please, check the query params.'); + return ; + } + return (
@@ -199,10 +222,7 @@ class Stream extends React.Component { editName={editName} currentUsername={user.username} />} - {loggedIn && - !banned && - !temporarilySuspended && - !highlightedComment && + {showCommentBox && } @@ -304,6 +324,7 @@ class Stream extends React.Component { topLevel={true} moreComments={asset.comments.hasNextPage} loadMore={this.loadMoreComments} + loadingState={loadingState} />
}
diff --git a/client/coral-embed-stream/src/components/StreamError.css b/client/coral-embed-stream/src/components/StreamError.css new file mode 100644 index 000000000..908d963c0 --- /dev/null +++ b/client/coral-embed-stream/src/components/StreamError.css @@ -0,0 +1,3 @@ +.streamError { + padding: 10px 0; +} \ No newline at end of file diff --git a/client/coral-embed-stream/src/components/StreamError.js b/client/coral-embed-stream/src/components/StreamError.js new file mode 100644 index 000000000..2e8b8ff96 --- /dev/null +++ b/client/coral-embed-stream/src/components/StreamError.js @@ -0,0 +1,9 @@ +import React from 'react'; +import styles from './StreamError.css'; +import t from 'coral-framework/services/i18n'; + +export const StreamError = () => ( +
+ {t('common.error')} +
+); diff --git a/client/coral-embed-stream/src/components/TopRightMenu.js b/client/coral-embed-stream/src/components/TopRightMenu.js index d65722b6d..4cd544379 100644 --- a/client/coral-embed-stream/src/components/TopRightMenu.js +++ b/client/coral-embed-stream/src/components/TopRightMenu.js @@ -12,7 +12,7 @@ export class TopRightMenu extends React.Component { comment: PropTypes.shape({ user: PropTypes.shape({ id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired + username: PropTypes.string.isRequired }).isRequired }).isRequired, ignoreUser: PropTypes.func, diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index f5682bdd1..040d1a83e 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -34,7 +34,7 @@ export default withFragments({ } user { id - name: username + username } action_summaries { __typename diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 68f76ccc5..bf05caf29 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -12,6 +12,8 @@ import {getDefinitionName} from 'coral-framework/utils'; import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; import Stream from './Stream'; +import {addNotification} from 'coral-framework/actions/notification'; +import t from 'coral-framework/services/i18n'; import {setActiveTab} from '../actions/embed'; import {viewAllComments} from '../actions/stream'; @@ -20,12 +22,63 @@ const {logout, checkLogin} = authActions; const {fetchAssetSuccess} = assetActions; class EmbedContainer extends React.Component { + subscriptions = []; + + subscribeToUpdates(props = this.props) { + if (props.auth.loggedIn) { + const newSubscriptions = [{ + document: USER_BANNED_SUBSCRIPTION, + updateQuery: () => { + addNotification('info', t('your_account_has_been_banned')); + }, + }, + { + document: USER_SUSPENDED_SUBSCRIPTION, + updateQuery: () => { + addNotification('info', t('your_account_has_been_suspended')); + }, + }, + { + document: USERNAME_REJECTED_SUBSCRIPTION, + updateQuery: () => { + addNotification('info', t('your_username_has_been_rejected')); + }, + }]; + + this.subscriptions = newSubscriptions.map((s) => props.data.subscribeToMore({ + document: s.document, + variables: { + user_id: props.auth.user.id, + }, + updateQuery: s.updateQuery, + })); + } + } + + unsubscribe() { + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions = []; + } + + resubscribe(props) { + this.unsubscribe(); + this.subscribeToUpdates(props); + } + + componentDidMount() { + this.subscribeToUpdates(); + } + + componentWillUnmount() { + this.unsubscribe(); + } componentWillReceiveProps(nextProps) { if (this.props.auth.loggedIn !== nextProps.auth.loggedIn) { // Refetch after login/logout. this.props.data.refetch(); + this.resubscribe(nextProps); } const {fetchAssetSuccess} = this.props; @@ -52,6 +105,45 @@ class EmbedContainer extends React.Component { } } +const USER_BANNED_SUBSCRIPTION = gql` + subscription UserBanned($user_id: ID!) { + userBanned(user_id: $user_id){ + id + status + canEditName + suspension { + until + } + } + } +`; + +const USER_SUSPENDED_SUBSCRIPTION = gql` + subscription UserSuspended($user_id: ID!) { + userSuspended(user_id: $user_id){ + id + status + canEditName + suspension { + until + } + } + } +`; + +const USERNAME_REJECTED_SUBSCRIPTION = gql` + subscription UsernameRejected($user_id: ID!) { + usernameRejected(user_id: $user_id){ + id + status + canEditName + suspension { + until + } + } + } +`; + const EMBED_QUERY = gql` query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) { asset(id: $assetId, url: $assetUrl) { @@ -95,6 +187,7 @@ const mapDispatchToProps = (dispatch) => setActiveTab, viewAllComments, fetchAssetSuccess, + addNotification, }, dispatch ); diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index f6e1fd9ec..3491fc846 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -30,11 +30,8 @@ class StreamContainer extends React.Component { subscriptions = []; subscribeToUpdates() { - const sub1 = this.props.data.subscribeToMore({ + const newSubscriptions = [{ document: COMMENTS_EDITED_SUBSCRIPTION, - variables: { - assetId: this.props.root.asset.id, - }, updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { // Ignore mutations from me. @@ -52,13 +49,9 @@ class StreamContainer extends React.Component { return removeCommentFromEmbedQuery(prev, commentEdited.id); } }, - }); - - const sub2 = this.props.data.subscribeToMore({ + }, + { document: COMMENTS_ADDED_SUBSCRIPTION, - variables: { - assetId: this.props.root.asset.id, - }, updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => { // Ignore mutations from me. @@ -81,9 +74,15 @@ class StreamContainer extends React.Component { return insertCommentIntoEmbedQuery(prev, commentAdded); } - }); + }]; - this.subscriptions.push(sub1, sub2); + this.subscriptions = newSubscriptions.map((s) => this.props.data.subscribeToMore({ + document: s.document, + variables: { + assetId: this.props.root.asset.id, + }, + updateQuery: s.updateQuery, + })); } unsubscribe() { @@ -173,7 +172,7 @@ const commentFragment = gql` `; const COMMENTS_ADDED_SUBSCRIPTION = gql` - subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){ + subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean){ commentAdded(asset_id: $assetId){ parent { id @@ -185,7 +184,7 @@ const COMMENTS_ADDED_SUBSCRIPTION = gql` `; const COMMENTS_EDITED_SUBSCRIPTION = gql` - subscription onCommentEdited($assetId: ID!){ + subscription CommentEdited($assetId: ID!){ commentEdited(asset_id: $assetId){ id body diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 701a06fd5..9b1232e3b 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -197,12 +197,12 @@ hr { } /* Comment Box Styles */ -.coral-plugin-commentbox-container { +.talk-plugin-commentbox-container { display: flex; width: 100%; } -.coral-plugin-commentbox-textarea { +.talk-plugin-commentbox-textarea { color: #262626; flex: 1; padding: 1em; @@ -212,13 +212,13 @@ hr { border: 1px solid #9E9E9E; } -.coral-plugin-commentbox-button-container { +.talk-plugin-commentbox-button-container { display: flex; justify-content: flex-end; margin-top: 10px; } -.coral-plugin-commentbox-button { +.talk-plugin-commentbox-button { float: right; margin-top: 10px; padding: 5px 10px; @@ -228,18 +228,18 @@ hr { border-radius: 2px; } -.coral-plugin-commentbox-username { +.talk-plugin-commentbox-username { width: 50%; padding-left: 5px; margin-bottom: 5px; } -.coral-plugin-commentbox-char-count { +.talk-plugin-commentbox-char-count { color: #ccc; text-align: right; } -.coral-plugin-commentbox-char-max { +.talk-plugin-commentbox-char-max { color: #d50000; } @@ -335,33 +335,6 @@ button.comment__action-button[disabled], display: inline-block; } -.coral-plugin-permalinks-container { - /*position: relative;*/ - z-index: 2; -} - -.coral-plugin-permalinks-popover { - display: none; - background-color: white; - border: 1px solid black; - position: absolute; - padding: 5px; -} - -.coral-plugin-permalinks-popover.active { - display: block; -} - -.coral-plugin-permalinks-copy-field { - display: block; - width: calc(100% - 5px); -} - -.coral-plugin-permalinks-copied-text { - float: right; - margin: 8px; -} - /* Flag Styles */ .coral-plugin-flags-popup-form { @@ -444,11 +417,11 @@ button.comment__action-button[disabled], /* Load More */ -.coral-load-more { +.talk-load-more { text-align: center; } -.coral-load-more button { +.talk-load-more button { text-align: center; color: #FFF; background-color: #2376D8; @@ -460,11 +433,11 @@ button.comment__action-button[disabled], display: inline-block; } -.coral-load-more:hover button { +.talk-load-more:hover button { background-color: #4399FF; } -.coral-load-more-replies, .coral-new-comments { +.talk-load-more-replies, .coral-new-comments { width: 100%; display: flex; justify-content: center; @@ -477,7 +450,7 @@ button.comment__action-button[disabled], z-index: 100; } -.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{ +.talk-load-more-replies button.talk-load-more, .coral-new-comments button.talk-load-more{ width: initial; } diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 5b42fa541..702308c3a 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -1,4 +1,5 @@ import pym from 'pym.js'; +import URLSearchParams from 'url-search-params'; import {stringify} from 'querystring'; @@ -28,6 +29,7 @@ const snackbarStyles = { // This function should return value of window.Coral const Coral = {}; const Talk = (Coral.Talk = {}); +let notificationTimeout = null; // build the URL to load in the pym iframe function buildStreamIframeUrl(talkBaseUrl, query) { @@ -110,14 +112,15 @@ function configurePymParent(pymParent, opts) { snackbar.className = `coral-notif-${type}`; snackbar.textContent = text; - setTimeout(() => { + clearTimeout(notificationTimeout); + notificationTimeout = setTimeout(() => { snackbar.style.transform = 'translate(-50%, 0)'; snackbar.style.opacity = 1; - }, 0); - setTimeout(() => { - snackbar.style.opacity = 0; - }, 5000); + notificationTimeout = setTimeout(() => { + snackbar.style.opacity = 0; + }, 7000); + }, 0); }); // Helps child show notifications at the right scrollTop @@ -188,7 +191,10 @@ Talk.render = function(el, opts) { // Compose the query to send down to the Talk API so it knows what to load. let query = {}; - query.comment_id = window.location.hash.slice(1); + let urlParams = new URLSearchParams(window.location.search); + + query.comment_id = urlParams.get('commentId'); + query.asset_id = opts.asset_id; query.asset_url = opts.asset_url; diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 5a05de28e..7ab12cf53 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -151,6 +151,20 @@ export default function auth (state = initialState, action) { case actions.SET_REDIRECT_URI: return state .set('redirectUri', action.uri); + case 'APOLLO_SUBSCRIPTION_RESULT': + if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) { + return state + .mergeIn(['user'], action.result.data.userBanned); + } + if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) { + return state + .mergeIn(['user'], action.result.data.userSuspended); + } + if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) { + return state + .mergeIn(['user'], action.result.data.usernameRejected); + } + return state; default : return state; } diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 0b79b7b02..1866005be 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -1,4 +1,5 @@ import {gql} from 'react-apollo'; +import t from 'coral-framework/services/i18n'; export const getTotalActionCount = (type, comment) => { return comment.action_summaries @@ -126,3 +127,20 @@ export function createDefaultResponseFragments(...names) { }); return result; } + +export function forEachError(error, callback) { + const errors = error.errors || [error]; + errors.forEach((e) => { + console.error(e); + + let msg = ''; + if (e.translation_key) { + msg = t(`error.${e.translation_key}`); + } else if (error.networkError) { + msg = t('error.network_error'); + } else { + msg = t('error.unexpected'); + } + callback({error: e, msg}); + }); +} diff --git a/client/coral-plugin-author-name/AuthorName.js b/client/coral-plugin-author-name/AuthorName.js index 66b907ad8..7d453d96f 100644 --- a/client/coral-plugin-author-name/AuthorName.js +++ b/client/coral-plugin-author-name/AuthorName.js @@ -24,7 +24,7 @@ export default class AuthorName extends Component { return (
- {author && author.name} + {author && author.username}
); } diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 5105667ed..6e17b119f 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -1,12 +1,14 @@ import React, {PropTypes} from 'react'; import t from 'coral-framework/services/i18n'; +import {can} from 'coral-framework/services/perms'; +import {forEachError} from 'coral-framework/utils'; import Slot from 'coral-framework/components/Slot'; import {connect} from 'react-redux'; import {CommentForm} from './CommentForm'; -export const name = 'coral-plugin-commentbox'; +export const name = 'talk-plugin-commentbox'; // Given a newly posted comment's status, show a notification to the user // if needed @@ -27,36 +29,45 @@ class CommentBox extends React.Component { this.state = { username: '', + body: '', + loadingState: '', - // incremented on successful post to clear form - postedCount: 0, hooks: { preSubmit: [], postSubmit: [] } }; } - postComment = ({body}) => { + + handleSubmit = () => { const { commentPostedHandler, postComment, assetId, parentId, addNotification, + currentUser, } = this.props; + if (!can(currentUser, 'INTERACT_WITH_COMMUNITY')) { + addNotification('error', t('error.NOT_AUTHORIZED')); + return; + } + let comment = { asset_id: assetId, parent_id: parentId, - body, + body: this.state.body, ...this.props.commentBox }; // Execute preSubmit Hooks this.state.hooks.preSubmit.forEach((hook) => hook()); + this.setState({loadingState: 'loading'}); postComment(comment, 'comments') .then(({data}) => { + this.setState({loadingState: 'success'}); const postedComment = data.createComment.comment; // Execute postSubmit Hooks @@ -67,12 +78,17 @@ class CommentBox extends React.Component { if (commentPostedHandler) { commentPostedHandler(); } + + this.setState({body: ''}); }) .catch((err) => { - console.error(err); + this.setState({loadingState: 'error'}); + forEachError(err, ({msg}) => addNotification('error', msg)); }); + } - this.setState({postedCount: this.state.postedCount + 1}); + handleBodyChange = (body) => { + this.setState({body}); } registerHook = (hookType = '', hook = () => {}) => { @@ -123,21 +139,17 @@ class CommentBox extends React.Component { }); } - handleChange = (e) => this.setState({body: e.target.value}); - render () { - const {styles, isReply, authorId, maxCharCount} = this.props; - let {cancelButtonClicked} = this.props; + const {isReply, maxCharCount} = this.props; + let {onCancel} = this.props; - if (isReply && typeof cancelButtonClicked !== 'function') { - console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply'); - cancelButtonClicked = () => {}; + if (isReply && typeof onCancel !== 'function') { + console.warn('the CommentBox component should have a onCancel callback defined if it lives in a Reply'); + onCancel = () => {}; } return
} - cancelButtonClicked={cancelButtonClicked} + onBodyChange={this.handleBodyChange} + loadingState={this.state.loadingState} + onCancel={onCancel} + onSubmit={this.handleSubmit} />
; } @@ -167,12 +182,13 @@ CommentBox.propTypes = { maxCharCount: PropTypes.number, commentPostedHandler: PropTypes.func, postComment: PropTypes.func.isRequired, - cancelButtonClicked: PropTypes.func, + onCancel: PropTypes.func, assetId: PropTypes.string.isRequired, parentId: PropTypes.string, - authorId: PropTypes.string.isRequired, + currentUser: PropTypes.object.isRequired, isReply: PropTypes.bool.isRequired, canPost: PropTypes.bool, + addNotification: PropTypes.func.isRequired, }; const mapStateToProps = ({commentBox}) => ({commentBox}); diff --git a/client/coral-plugin-commentbox/CommentForm.js b/client/coral-plugin-commentbox/CommentForm.js index 4ce32c11f..fe0f9f6d9 100644 --- a/client/coral-plugin-commentbox/CommentForm.js +++ b/client/coral-plugin-commentbox/CommentForm.js @@ -1,9 +1,10 @@ import React, {PropTypes} from 'react'; import {Button} from 'coral-ui'; -import classnames from 'classnames'; +import cn from 'classnames'; import Slot from 'coral-framework/components/Slot'; import {name} from './CommentBox'; +import styles from './styles.css'; import t from 'coral-framework/services/i18n'; @@ -13,15 +14,8 @@ import t from 'coral-framework/services/i18n'; export class CommentForm extends React.Component { static propTypes = { - // Initial value for underlying comment body textarea - defaultValue: PropTypes.string, charCountEnable: PropTypes.bool.isRequired, maxCharCount: PropTypes.number, - cancelButtonClicked: PropTypes.func, - - // Save the comment in the form. - // Will be passed { body: String } - saveComment: PropTypes.func.isRequired, // DOM ID for form input that edits comment body bodyInputId: PropTypes.string, @@ -38,53 +32,63 @@ export class CommentForm extends React.Component { // render inside submit button submitText: PropTypes.node, - styles: PropTypes.shape({ - textarea: PropTypes.string - }), + // cStyle for enabled submit + submitButtonCStyle: PropTypes.string, - // cStyle for enabled save - saveButtonCStyle: PropTypes.string, - - // return whether the save button should be enabled for the provided + // return whether the submit button should be enabled for the provided // comment ({ body }) (for reasons other than charCount) - saveCommentEnabled: PropTypes.func, + submitEnabled: PropTypes.func, // className to add to buttons - buttonClass: PropTypes.string, + submitButtonClassName: PropTypes.string, + cancelButtonClassName: PropTypes.string, + + body: PropTypes.string.isRequired, + onBodyChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func, + state: PropTypes.string, + loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']), } static get defaultProps() { return { bodyLabel: t('comment_box.comment'), bodyPlaceholder: t('comment_box.comment'), submitText: t('comment_box.post'), - saveButtonCStyle: 'darkGrey', - saveCommentEnabled: () => true, + submitButtonCStyle: 'darkGrey', + submitEnabled: () => true, }; } - constructor(props) { - super(props); - this.onBodyChange = this.onBodyChange.bind(this); - this.onClickSubmit = this.onClickSubmit.bind(this); - this.state = { - body: props.defaultValue || '' - }; - } - onBodyChange(e) { - this.setState({body: e.target.value}); - } - onClickSubmit(e) { - e.preventDefault(); - const {saveComment} = this.props; - const {body} = this.state; - saveComment({body}); - } - render() { - const {maxCharCount, styles, saveCommentEnabled, buttonClass, charCountEnable} = this.props; - const body = this.state.body; + onBodyChange = (e) => { + this.props.onBodyChange(e.target.value); + } + + onClickSubmit = () => { + this.props.onSubmit(); + } + + getButtonClassName = () => { + switch (this.props.loadingState) { + case 'loading': + return cn(`${name}-button-loading`, styles.buttonLoading); + case 'success': + return cn(`${name}-button-success`, styles.buttonSuccess); + case 'error': + return cn(`${name}-button-error`, styles.buttonError); + default: + return ''; + } + } + + render() { + const {maxCharCount, submitEnabled, cancelButtonClassName, submitButtonClassName, charCountEnable, body, loadingState} = this.props; + const length = body.length; const isRespectingMaxCount = (length) => charCountEnable && maxCharCount && length > maxCharCount; - const disablePostComment = !length || isRespectingMaxCount(length) || !saveCommentEnabled({body}); + const disableSubmitButton = !length || isRespectingMaxCount(length) || !submitEnabled({body}) || loadingState === 'loading'; + const disableCancelButton = loadingState === 'loading'; + const disableTextArea = loadingState === 'loading'; return
@@ -95,13 +99,14 @@ export class CommentForm extends React.Component { {this.props.bodyLabel}