Merge branch 'master' into keyboard-shortcuts

This commit is contained in:
Kim Gardner
2017-03-03 09:59:45 -05:00
committed by GitHub
36 changed files with 1138 additions and 457 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "../.babelrc",
"plugins": [
"transform-async-to-generator",
]
}
@@ -14,28 +14,35 @@ const CoralHeader = ({handleLogout, restricted = false}) => (
<div>
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</IndexLink>
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
<Link className={styles.navLink}
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/streams"
activeClassName={styles.active}>
{lang.t('configure.streams')}
</Link>
<Link className={styles.navLink}
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
@@ -1,3 +1,7 @@
.Reply {
position: relative;
}
.Comment {
}
+84 -27
View File
@@ -17,6 +17,7 @@ import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import LikeButton from 'coral-plugin-likes/LikeButton';
import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton';
import LoadMore from 'coral-embed-stream/src/LoadMore';
import styles from './Comment.css';
@@ -25,6 +26,11 @@ const getActionSummary = (type, comment) => comment.action_summaries
.filter((a) => a.__typename === type)[0];
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ;
// hold actions links (e.g. Like, Reply) along the comment footer
const ActionButton = ({children}) => {
return <span className="comment__action-button comment__action-button--nowrap">{ children }</span>;
};
class Comment extends React.Component {
constructor(props) {
@@ -74,7 +80,13 @@ class Comment extends React.Component {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired
}).isRequired
}).isRequired,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
}
render () {
@@ -94,7 +106,9 @@ class Comment extends React.Component {
loadMore,
setActiveReplyBox,
activeReplyBox,
deleteAction
deleteAction,
addCommentTag,
removeCommentTag,
} = this.props;
const like = getActionSummary('LikeActionSummary', comment);
@@ -103,6 +117,27 @@ class Comment extends React.Component {
let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`;
commentClass += highlighted === comment.id ? ' highlighted-comment' : '';
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
const notifyOnError = (fn, errorToMessage) => async () => {
if (typeof errorToMessage !== 'function') {errorToMessage = (error) => error.message;}
try {
return await fn();
} catch (error) {
addNotification('error', errorToMessage(error));
throw error;
}
};
const addBestTag = notifyOnError(() => addCommentTag({
id: comment.id,
tag: BEST_TAG,
}), () => 'Failed to tag comment as best');
const removeBestTag = notifyOnError(() => removeCommentTag({
id: comment.id,
tag: BEST_TAG,
}), () => 'Failed to remove best comment tag');
return (
<div
className={commentClass}
@@ -112,35 +147,55 @@ class Comment extends React.Component {
<AuthorName
author={comment.user}/>
{ isStaff(comment.tags)
? <TagLabel isStaff={true}/>
? <TagLabel>Staff</TagLabel>
: null }
{ commentIsBest(comment)
? <TagLabel><BestIndicator /></TagLabel>
: null }
<PubDate created_at={comment.created_at} />
<Content body={comment.body} />
<div className="commentActionsLeft">
<LikeButton
like={like}
id={comment.id}
postLike={postLike}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
<ReplyButton
onClick={() => setActiveReplyBox(comment.id)}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false} />
<div className="commentActionsLeft comment__action-container">
<ActionButton>
<LikeButton
like={like}
id={comment.id}
postLike={postLike}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
</ActionButton>
<ActionButton>
<ReplyButton
onClick={() => setActiveReplyBox(comment.id)}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false} />
</ActionButton>
<ActionButton>
<IfUserCanModifyBest user={currentUser}>
<BestButton
isBest={commentIsBest(comment)}
addBest={addBestTag}
removeBest={removeBestTag} />
</IfUserCanModifyBest>
</ActionButton>
</div>
<div className="commentActionsRight">
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
<FlagComment
flag={flag && flag.current_user ? flag : dontagree}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
<div className="commentActionsRight comment__action-container">
<ActionButton>
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
</ActionButton>
<ActionButton>
<FlagComment
flag={flag && flag.current_user ? flag : dontagree}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
</ActionButton>
</div>
{
activeReplyBox === comment.id
@@ -172,6 +227,8 @@ class Comment extends React.Component {
postLike={postLike}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
+30 -18
View File
@@ -13,7 +13,7 @@ const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
import {queryStream} from 'coral-framework/graphql/queries';
import {postComment, postFlag, postLike, postDontAgree, deleteAction} from 'coral-framework/graphql/mutations';
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
import {editName} from 'coral-framework/actions/user';
import {updateCountCache} from 'coral-framework/actions/asset';
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
@@ -55,7 +55,13 @@ class Embed extends Component {
data: React.PropTypes.shape({
loading: React.PropTypes.bool,
error: React.PropTypes.object
}).isRequired
}).isRequired,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
}
componentDidMount () {
@@ -194,22 +200,26 @@ class Embed extends Component {
assetId={asset.id}
updateCountCache={this.props.updateCountCache}
/>
<Stream
addNotification={this.props.addNotification}
postItem={this.props.postItem}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.state.activeReplyBox}
asset={asset}
currentUser={user}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
getCounts={this.props.getCounts}
updateCountCache={this.props.updateCountCache}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
<div className="embed__stream">
<Stream
addNotification={this.props.addNotification}
postItem={this.props.postItem}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.state.activeReplyBox}
asset={asset}
currentUser={user}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
getCounts={this.props.getCounts}
addCommentTag={this.props.addCommentTag}
removeCommentTag={this.props.removeCommentTag}
updateCountCache={this.props.updateCountCache}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
</div>
<Notification
notifLength={4500}
clearNotification={this.props.clearNotification}
@@ -278,6 +288,8 @@ export default compose(
postFlag,
postLike,
postDontAgree,
addCommentTag,
removeCommentTag,
deleteAction,
queryStream
)(Embed);
+13 -3
View File
@@ -12,7 +12,13 @@ class Stream extends React.Component {
currentUser: PropTypes.shape({
username: PropTypes.string,
id: PropTypes.string
})
}),
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
}
constructor(props) {
@@ -52,11 +58,13 @@ class Stream extends React.Component {
postDontAgree,
loadMore,
deleteAction,
showSignInDialog
showSignInDialog,
addCommentTag,
removeCommentTag
} = this.props;
return (
<div>
<div id='stream'>
{
comments.map(comment =>
<Comment
@@ -70,6 +78,8 @@ class Stream extends React.Component {
postLike={postLike}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
+26 -5
View File
@@ -172,7 +172,7 @@ hr {
.coral-plugin-author-name-text {
display: inline-block;
margin: 10px 8px 10px 0;
margin: 10px 5px 10px 0;
font-weight: bold;
}
@@ -191,7 +191,7 @@ hr {
background-color: #4C1066;
color: white;
display: inline-block;
margin: 10px 10px;
margin: 0px 5px;
padding: 5px 5px;
border-radius: 2px;
}
@@ -218,10 +218,30 @@ hr {
width: 50%;
}
.material-icons {
font-size: 12px !important;
margin-left: 3px;
.comment__action-container .material-icons {
font-size: 12px;
margin-left: 3px;
}
button.comment__action-button,
.comment__action-button button {
cursor: pointer;
}
button.comment__action-button[disabled],
.comment__action-button[disabled] button {
cursor: inherit;
}
.comment__action-button--nowrap {
white-space: nowrap;
}
.commentStream .material-icons {
vertical-align: middle;
width: 1em;
font-size: 1em;
overflow: hidden;
}
.likedButton {
@@ -236,6 +256,7 @@ hr {
color: #696969;
display: inline-block;
font-size: .75rem;
margin-left: 5px;
}
.coral-plugin-permalinks-container {
+5 -4
View File
@@ -31,13 +31,14 @@ function buildStreamIframeUrl(talkBaseUrl, asset_url, comment, asset_id) {
function configurePymParent(pymParent, asset_url) {
let notificationOffset = 200;
let ready = false;
let cachedHeight;
// Resize parent iframe height when child height changes
pymParent.onMessage('height', function(height) {
// TODO: In local testing, this is firing nonstop. Maybe there's a bug on the inside?
// Or it's by design of pym... but that's very wasteful of CPU and DOM reflows (jank)
pymParent.el.querySelector('iframe').height = `${height }px`;
if (height !== cachedHeight) {
pymParent.el.firstChild.style.height = `${height}px`;
cachedHeight = height;
}
});
// Helps child show notifications at the right scrollTop
@@ -0,0 +1,13 @@
mutation AddCommentTag ($id: ID!, $tag: String!) {
addCommentTag(id:$id, tag:$tag) {
comment {
id
tags {
name
}
}
errors {
translation_key
}
}
}
@@ -4,6 +4,8 @@ import POST_FLAG from './postFlag.graphql';
import POST_LIKE from './postLike.graphql';
import POST_DONT_AGREE from './postDontAgree.graphql';
import DELETE_ACTION from './deleteAction.graphql';
import ADD_COMMENT_TAG from './addCommentTag.graphql';
import REMOVE_COMMENT_TAG from './removeCommentTag.graphql';
import commentView from '../fragments/commentView.graphql';
@@ -122,3 +124,27 @@ export const deleteAction = graphql(DELETE_ACTION, {
});
}}),
});
export const addCommentTag = graphql(ADD_COMMENT_TAG, {
props: ({mutate}) => ({
addCommentTag: ({id, tag}) => {
return mutate({
variables: {
id,
tag
}
});
}}),
});
export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, {
props: ({mutate}) => ({
removeCommentTag: ({id, tag}) => {
return mutate({
variables: {
id,
tag
}
});
}}),
});
@@ -0,0 +1,13 @@
mutation RemoveCommentTag ($id: ID!, $tag: String!) {
removeCommentTag(id:$id, tag:$tag) {
comment {
id
tags {
name
}
}
errors {
translation_key
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import React, {Component, PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import classnames from 'classnames';
// tag string for best comments
export const BEST_TAG = 'BEST';
export const commentIsBest = ({tags} = {}) => {
const isBest = Array.isArray(tags) && tags.some(t => t.name === BEST_TAG);
return isBest;
};
const name = 'coral-plugin-best';
const lang = new I18n(translations);
// It would be best if the backend/api held this business logic
const canModifyBestTag = ({roles = []} = {}) => roles && ['ADMIN', 'MODERATOR'].some(role => roles.includes(role));
// Put this on a comment to show that it is best
export const BestIndicator = ({children = <i className={'material-icons'} aria-hidden={true}>favorite</i>}) => (
<span aria-label={lang.t('commentIsBest')}>
{ children }
</span>
);
/**
* Component that only renders children if the provided user prop can modify best tags
*/
export const IfUserCanModifyBest = ({user, children}) => {
if ( ! ( user && canModifyBestTag(user))) {return null;}
return children;
};
/**
* Button that lets a moderator tag a comment as "Best".
* Used to recognize really good comments.
*/
export class BestButton extends Component {
static propTypes = {
// whether the comment is already tagged as best
isBest: PropTypes.bool.isRequired,
// set that this comment is best
addBest: PropTypes.func.isRequired,
// remove the best status
removeBest: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.onClickAddBest = this.onClickAddBest.bind(this);
this.onClickRemoveBest = this.onClickRemoveBest.bind(this);
}
state = {
isSaving: false
}
async onClickAddBest(e) {
e.preventDefault();
const {addBest} = this.props;
if ( ! addBest) {
console.warn('BestButton#onClickAddBest called even though there is no addBest prop. doing nothing');
return;
}
this.setState({isSaving: true});
try {
await addBest();
} finally {
this.setState({isSaving: false});
}
}
async onClickRemoveBest(e) {
e.preventDefault();
const {removeBest} = this.props;
if ( ! removeBest) {
console.warn('BestButton#onClickAddBest called even though there is no removeBest prop. doing nothing');
return;
}
this.setState({isSaving: true});
try {
await removeBest();
} finally {
this.setState({isSaving: false});
}
}
render() {
const {isBest, addBest, removeBest} = this.props;
const {isSaving} = this.state;
const disabled = isSaving || ! (isBest ? removeBest : addBest);
return (
<button onClick={isBest ? this.onClickRemoveBest : this.onClickAddBest}
disabled={disabled}
className={classnames(`${name}-button`, `e2e__${isBest ? 'unset' : 'set'}-best-comment`)}
aria-label={lang.t(isBest ? 'unsetBest' : 'setBest')}>
<i className={`${name}-icon material-icons`} aria-hidden={true}>
{ isBest ? 'favorite' : 'favorite_border' }
</i>
</button>
);
}
}
@@ -0,0 +1,12 @@
{
"en": {
"setBest": "Tag as Best",
"unsetBest": "Untag as Best",
"commentIsBest": "This comment is one of the best"
},
"es": {
"like": "Establecer como mejor",
"liked": "Desarmado como mejor",
"commentIsBest": "Este comentario es uno de los mejores"
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ class LikeButton extends Component {
};
return <div className={`${name}-container`}>
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
<button onClick={onLikeClick} className={`${name}-button ${liked ? 'likedButton' : ''}`}>
<span className={`${name}-button-text`}>{lang.t(liked ? 'liked' : 'like')}</span>
<i className={`${name}-icon material-icons`}
aria-hidden={true}>thumb_up</i>
+2 -1
View File
@@ -1,13 +1,14 @@
import React, {PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import classnames from 'classnames';
const name = 'coral-plugin-replies';
const ReplyButton = ({banned, onClick}) => {
return (
<button
className={`${name}-reply-button`}
className={classnames(`${name}-reply-button`)}
onClick={onClick}>
{lang.t('reply')}
<i className={`${name}-icon material-icons`}
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react';
const TagLabel = ({isStaff}) => <div className='coral-plugin-tag-label'>
{isStaff ? 'Staff' : ''}
const TagLabel = ({children}) => <div className='coral-plugin-tag-label'>
{children}
</div>;
export default TagLabel;