Merge branch 'master' into i18n-refactor

This commit is contained in:
gaba
2017-05-15 14:02:34 -07:00
251 changed files with 4984 additions and 1874 deletions
@@ -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;
}
@@ -19,11 +19,13 @@ 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');
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
// hold actions links (e.g. Reply) along the comment footer
const ActionButton = ({children}) => {
@@ -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 = {
@@ -53,7 +65,7 @@ class Comment extends React.Component {
parentId: PropTypes.string,
highlighted: PropTypes.string,
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
asset: PropTypes.shape({
id: PropTypes.string,
@@ -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,17 +115,53 @@ 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,
currentUser,
asset,
depth,
postItem,
postComment,
addNotification,
showSignInDialog,
highlighted,
@@ -133,9 +187,9 @@ class Comment extends React.Component {
);
let myFlag = null;
if (iPerformedThisAction('FlagActionSummary', comment)) {
myFlag = flagSummary.find(s => s.current_user);
myFlag = flagSummary.find((s) => s.current_user);
} else if (iPerformedThisAction('DontAgreeActionSummary', comment)) {
myFlag = dontAgreeSummary.find(s => s.current_user);
myFlag = dontAgreeSummary.find((s) => s.current_user);
}
let commentClass = parentId
@@ -147,7 +201,7 @@ class Comment extends React.Component {
const notifyOnError = (fn, errorToMessage) =>
async function(...args) {
if (typeof errorToMessage !== 'function') {
errorToMessage = error => error.message;
errorToMessage = (error) => error.message;
}
try {
return await fn(...args);
@@ -190,8 +244,17 @@ class Comment extends React.Component {
{commentIsBest(comment)
? <TagLabel><BestIndicator /></TagLabel>
: null}
<PubDate created_at={comment.created_at} />
: null }
<span className={styles.bylineSecondary}>
<PubDate created_at={comment.created_at} />
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>(Edited)</span></span>
: null
}
</span>
<Slot
fill="commentInfoBar"
data={this.props.data}
@@ -200,18 +263,47 @@ class Comment extends React.Component {
commentId={comment.id}
inline
/>
{currentUser && comment.user.id !== currentUser.id
? <span className={styles.topRightMenu}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification}
/>
</span>
: null}
<Content body={comment.body} />
<Slot fill="commentContent" />
{ (currentUser &&
(comment.user.id === currentUser.id))
/* User can edit/delete their own comment for a short window after posting */
? <span className={classnames(styles.topRight)}>
{
commentIsStillEditable(comment) &&
<a
className={classnames(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
/* TopRightMenu allows currentUser to ignore other users' comments */
: <span className={classnames(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
}
{
this.state.isEditing
? <EditableCommentContent
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
addNotification={addNotification}
asset={asset}
comment={comment}
currentUser={currentUser}
maxCharCount={maxCharCount}
parentId={parentId}
stopEditing={this.stopEditing}
/>
: <div>
<Content body={comment.body} />
<Slot fill="commentContent" />
</div>
}
<div className="commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
@@ -278,12 +370,12 @@ class Comment extends React.Component {
parentId={parentId || comment.id}
addNotification={addNotification}
authorId={currentUser.id}
postItem={postItem}
postComment={postComment}
assetId={asset.id}
/>
: null}
{comment.replies &&
comment.replies.map(reply => {
comment.replies.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
@@ -294,7 +386,8 @@ class Comment extends React.Component {
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postItem={postItem}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
@@ -330,3 +423,21 @@ class Comment extends React.Component {
}
export default Comment;
// return whether the comment is editable
function commentIsStillEditable (comment) {
const editing = comment && comment.editing;
if (!editing) {return false;}
const editableUntil = getEditableUntilDate(comment);
const editWindowExpired = (editableUntil - new Date) < 0;
return !editWindowExpired;
}
// return number of milliseconds before edit window expires
function editWindowRemainingMs (comment) {
const editableUntil = getEditableUntilDate(comment);
if (!editableUntil) {return;}
const now = new Date();
const editWindowRemainingMs = (editableUntil - now);
return editWindowRemainingMs;
}
@@ -0,0 +1,53 @@
import React, {PropTypes} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations';
const lang = new I18n(translations);
/**
* Countdown the number of seconds until a given Date
*/
export class CountdownSeconds extends React.Component {
static propTypes = {
until: PropTypes.instanceOf(Date).isRequired,
classNameForMsRemaining: PropTypes.func,
}
constructor(props) {
super(props);
this.countdownInterval = null;
}
componentDidMount() {
const {until} = this.props;
const now = new Date();
if (until - now > 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 (
<span className={classFromProp}>
{`${wholeSecRemaining} ${units}`}
</span>
);
}
}
@@ -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.${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 (
<div className={styles.editCommentForm}>
<CommentForm
defaultValue={this.props.comment.body}
charCountEnable={this.props.asset.settings.charCountEnable}
maxCharCount={this.props.maxCharCount}
saveCommentEnabled={(comment) => {
// 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={<span>{lang.t('editComment.saveButton')}</span>}
saveButtonCStyle="green"
cancelButtonClicked={this.props.stopEditing}
buttonClass={styles.button}
buttonContainerStart={
<div className={styles.buttonContainerLeft}>
<span className={styles.editWindowRemaining}>
{
editWindowExpired
? <span>
{lang.t('editComment.editWindowExpired')}
{
typeof this.props.stopEditing === 'function'
? <span>&nbsp;<a className={styles.link} onClick={this.props.stopEditing}>{lang.t('editComment.editWindowExpiredClose')}</a></span>
: null
}
</span>
: <span>
<Icon name="timer"/> {lang.t('editComment.editWindowTimerPrefix')}
<CountdownSeconds
until={editableUntil}
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
/>
</span>
}
</span>
</div>
}
/>
</div>
);
}
}
@@ -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;
}
@@ -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 (
<div className={styles.IgnoreUserWizard}>
<div className={styles.Wizard}>
{ elForThisStep }
</div>
);
@@ -15,7 +15,7 @@ import ChangeUsernameContainer
from 'coral-sign-in/containers/ChangeUsernameContainer';
class Stream extends React.Component {
setActiveReplyBox = reactKey => {
setActiveReplyBox = (reactKey) => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
} else {
@@ -26,7 +26,7 @@ class Stream extends React.Component {
render() {
const {
root: {asset, asset: {comments}, comment, myIgnoredUsers},
postItem,
postComment,
addNotification,
postFlag,
postDontAgree,
@@ -58,7 +58,7 @@ class Stream extends React.Component {
const firstCommentDate = asset.comments[0]
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
const commentIsIgnored = comment =>
const commentIsIgnored = (comment) =>
myIgnoredUsers && myIgnoredUsers.includes(comment.user.id);
return (
<div id="stream">
@@ -84,7 +84,7 @@ class Stream extends React.Component {
{user
? <CommentBox
addNotification={this.props.addNotification}
postItem={this.props.postItem}
postComment={this.props.postComment}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
setCommentCountCache={this.props.setCommentCountCache}
@@ -122,7 +122,7 @@ class Stream extends React.Component {
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={this.props.postItem}
postComment={this.props.postComment}
asset={asset}
currentUser={user}
highlighted={comment.id}
@@ -137,6 +137,7 @@ class Stream extends React.Component {
comment={highlightedComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
/>
: <div>
<NewCount
@@ -149,7 +150,7 @@ class Stream extends React.Component {
/>
<div className="embed__stream">
{comments.map(
comment =>
(comment) =>
(commentIsIgnored(comment)
? <IgnoredCommentTombstone key={comment.id} />
: <Comment
@@ -160,7 +161,7 @@ class Stream extends React.Component {
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={postItem}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
@@ -178,6 +179,7 @@ class Stream extends React.Component {
pluginProps={pluginProps}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
/>)
)}
</div>
@@ -196,7 +198,7 @@ class Stream extends React.Component {
Stream.propTypes = {
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
// dispatch action to add a tag to a comment
addCommentTag: PropTypes.func,
@@ -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;
@@ -20,5 +20,4 @@
position: relative;
transform: rotate(180deg);
top: 0;
/*top: -0.25em;*/
}
@@ -72,7 +72,7 @@ class Toggleable extends React.Component {
};
}
toggle() {
this.setState({isOpen: ! this.state.isOpen});
this.setState({isOpen: !this.state.isOpen});
}
close() {
this.setState({isOpen: false});
@@ -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;
};