Merge branch 'master' into master

This commit is contained in:
Wyatt Johnson
2018-05-31 14:12:00 -06:00
committed by GitHub
251 changed files with 3942 additions and 2408 deletions
+11
View File
@@ -0,0 +1,11 @@
:global {
html, body, #root, #root > div {
min-height: 100%;
}
body {
margin: 0;
background-color: #FAFAFA;
font-family: 'Roboto', sans-serif;
}
}
+1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import ToastContainer from './ToastContainer';
import './App.css';
import 'material-design-lite';
import AppRouter from '../AppRouter';
@@ -14,7 +14,8 @@ const ApproveButton = ({ active, minimal, onClick, className, disabled }) => {
className={cn(
styles.root,
{ [styles.minimal]: minimal, [styles.active]: active },
className
className,
'talk-admin-approve-button'
)}
onClick={onClick}
disabled={disabled || active}
@@ -19,7 +19,7 @@ class BanUserDialog extends React.Component {
}
handleMessageChange = e => {
const { value: message } = e;
const { target: { value: message } } = e;
this.setState({ message });
};
@@ -30,6 +30,12 @@ class BanUserDialog extends React.Component {
});
};
handlePerform = () => {
this.props.onPerform({
message: this.state.message,
});
};
renderStep0() {
const { onCancel, username, info } = this.props;
@@ -63,7 +69,7 @@ class BanUserDialog extends React.Component {
}
renderStep1() {
const { onCancel, onPerform } = this.props;
const { onCancel } = this.props;
const { message } = this.state;
return (
@@ -95,7 +101,7 @@ class BanUserDialog extends React.Component {
<Button
className={cn('talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
onClick={this.handlePerform}
raised
>
{t('bandialog.send')}
@@ -29,7 +29,7 @@ const CommentAnimatedEdit = ({ children, body }) => {
CommentAnimatedEdit.propTypes = {
children: PropTypes.node,
body: PropTypes.string,
body: PropTypes.string.isRequired,
};
export default CommentAnimatedEdit;
@@ -0,0 +1,5 @@
.tombstone {
background-color: #f0f0f0;
padding: 1em;
color: #1a212f;
}
@@ -0,0 +1,9 @@
import React from 'react';
import styles from './CommentDeletedTombstone.css';
import t from 'coral-framework/services/i18n';
const CommentDeletedTombstone = () => (
<div className={styles.tombstone}>{t('framework.comment_is_deleted')}</div>
);
export default CommentDeletedTombstone;
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Label from 'coral-ui/components/Label';
import Slot from 'coral-framework/components/Slot';
import { t } from 'coral-framework/services/i18n';
import FlagLabel from 'coral-ui/components/FlagLabel';
import cn from 'classnames';
import styles from './CommentLabels.css';
@@ -63,10 +64,14 @@ const CommentLabels = ({
<FlagLabel iconName="person">{getUserFlaggedType(actions)}</FlagLabel>
)}
{hasSuspectedWords(actions) && (
<FlagLabel iconName="sms_failed">Suspect</FlagLabel>
<FlagLabel iconName="sms_failed">
{t('flags.reasons.comment.suspect_word')}
</FlagLabel>
)}
{hasHistoryFlag(actions) && (
<FlagLabel iconName="sentiment_very_dissatisfied">History</FlagLabel>
<FlagLabel iconName="sentiment_very_dissatisfied">
{t('flags.reasons.comment.trust')}
</FlagLabel>
)}
</div>
<Slot
@@ -0,0 +1,16 @@
.external {
margin-bottom: 20px;
}
.separator h5 {
text-align: center;
font-size: 1.2em;
}
.slot > * {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0px;
}
}
@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './External.css';
import Slot from 'coral-framework/components/Slot';
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
const External = ({ slot }) => (
<IfSlotIsNotEmpty slot={slot}>
<div>
<div className={styles.external}>
<Slot fill={slot} className={styles.slot} />
</div>
<div className={styles.separator}>
<h5>Or</h5>
</div>
</div>
</IfSlotIsNotEmpty>
);
External.propTypes = {
slot: PropTypes.string.isRequired,
};
export default External;
@@ -7,7 +7,6 @@ import styles from './Header.css';
import t from 'coral-framework/services/i18n';
import { Logo } from './Logo';
import { can } from 'coral-framework/services/perms';
import ModerationIndicator from '../routes/Moderation/containers/Indicator';
import CommunityIndicator from '../routes/Community/containers/Indicator';
const CoralHeader = ({
@@ -32,7 +31,6 @@ const CoralHeader = ({
activeClassName={styles.active}
>
{t('configure.moderate')}
<ModerationIndicator root={root} data={data} />
</IndexLink>
)}
<Link
@@ -1,10 +1,8 @@
.indicator {
display: inline-block;
background-color: #E46D59;
border-radius: 10px;
position: absolute;
top: 50%;
width: 7px;
height: 7px;
margin-top: -4px;
margin-left: 7px;
}
@@ -0,0 +1,103 @@
.karmaTooltip {
position: relative;
display: inline-block;
margin: 2px 4px 0;
}
.icon {
font-size: 16px;
color: #0D5B8F;
user-select: none;
-webkit-tap-highlight-color:rgba(0,0,0,0);
> i {
vertical-align: baseline;
}
}
.icon:hover {
cursor: pointer;
}
.menu {
background-color: white;
border: solid 1px #999;
border-radius: 3px;
padding: 10px;
position: absolute;
box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12), 0 3px 1px -2px rgba(0,0,0,0.2);
z-index: 10;
top: 32px;
left: -100px;
width: 150px;
text-align: left;
color: #616161;
}
.menu::before{
content: '';
border: 10px solid transparent;
border-top-color: #999;
position: absolute;
left: 96px;
top: -20px;
transform: rotate(180deg);
}
.menu::after{
content: '';
border: 10px solid transparent;
border-top-color: white;
position: absolute;
left: 96px;
top: -19px;
transform: rotate(180deg);
}
.menu ul {
list-style: none;
padding: 0;
li {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
}
.label {
padding: 4px 5px;
border-radius: 3px;
color: #fff;
font-weight: 400;
text-align: center;
font-size: .9em;
line-height: normal;
letter-spacing: .4px;
min-width: 25px;
display: block;
/* &.reliable { background-color: #03AB61; } */
/* &.neutral { background-color: #616161; } */
&.unreliable { background-color: #F44336; }
}
.descriptionList {
padding: 0;
margin: 0;
list-style: none;
}
.strongItem {
margin-right: 3px;
}
.descriptionItem {
font-size: 0.9em;
}
.link {
color: #2B7EB5;
text-decoration: underline;
display: block;
}
@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Icon } from 'coral-ui';
import styles from './KarmaTooltip.css';
import ClickOutside from 'coral-framework/components/ClickOutside';
import t from 'coral-framework/services/i18n';
const initialState = { menuVisible: false };
class KarmaTooltip extends React.Component {
static propTypes = {
thresholds: PropTypes.shape({
reliable: PropTypes.number.isRequired,
unreliable: PropTypes.number.isRequired,
}).isRequired,
};
state = initialState;
toogleMenu = () => {
this.setState({ menuVisible: !this.state.menuVisible });
};
hideMenu = () => {
this.setState({ menuVisible: false });
};
render() {
const { thresholds: { unreliable } } = this.props;
const { menuVisible } = this.state;
return (
<ClickOutside onClickOutside={this.hideMenu}>
<div className={cn(styles.karmaTooltip, 'talk-admin-karma-tooltip')}>
<span
onClick={this.toogleMenu}
className={cn(styles.icon, 'talk-admin-karma-tooltip-icon')}
>
<Icon name="info" />
</span>
{menuVisible && (
<div className={cn(styles.menu, 'talk-admin-karma-tooltip-menu')}>
<strong>{t('user_detail.user_karma_score')}</strong>
<ul>
{/* NOTE: we may display this data in the future, keeping around for that eventuality */}
{/* <li>
<span>Reliable</span>{' '}
<span className={cn(styles.label, styles.reliable)}>
&ge; {reliable}
</span>
</li>
<li>
<span>Neutral</span>{' '}
<span className={cn(styles.label, styles.neutral)}>
&lt; {reliable}, &gt; {unreliable}
</span>
</li> */}
<li>
<span>{t('user_detail.unreliable')}</span>{' '}
<span className={cn(styles.label, styles.unreliable)}>
&le; {unreliable}
</span>
</li>
</ul>
<a
className={styles.link}
href={t('user_detail.karma_docs_link')}
target="_blank"
>
{t('user_detail.learn_more')}
</a>
</div>
)}
</div>
</ClickOutside>
);
}
}
export default KarmaTooltip;
@@ -14,7 +14,8 @@ const RejectButton = ({ active, minimal, onClick, className, disabled }) => {
className={cn(
styles.root,
{ [styles.minimal]: minimal, [styles.active]: active },
className
className,
'talk-admin-reject-button'
)}
onClick={onClick}
disabled={disabled || active}
+49 -41
View File
@@ -4,6 +4,7 @@ import styles from './SignIn.css';
import { Button, TextField, Alert } from 'coral-ui';
import cn from 'classnames';
import Recaptcha from 'coral-framework/components/Recaptcha';
import External from './External';
class SignIn extends React.Component {
recaptcha = null;
@@ -33,48 +34,55 @@ class SignIn extends React.Component {
render() {
const { email, password, errorMessage, requireRecaptcha } = this.props;
return (
<form className="talk-admin-login-sign-in" onSubmit={this.handleSubmit}>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
id="email"
label="Email Address"
value={email}
onChange={this.handleEmailChange}
/>
<TextField
id="password"
label="Password"
value={password}
onChange={this.handlePasswordChange}
type="password"
/>
{requireRecaptcha && (
<div className={styles.recaptcha}>
<Recaptcha
ref={this.handleRecaptchaRef}
onVerify={this.props.onRecaptchaVerify}
/>
</div>
)}
<Button
className={cn(styles.signInButton, 'talk-admin-login-sign-in-button')}
type="submit"
cStyle="black"
full
>
Sign In
</Button>
<p className={styles.forgotPasswordCTA}>
Forgot your password?{' '}
<a
href="#"
className={styles.forgotPasswordLink}
onClick={this.handleForgotPasswordLink}
<div className="talk-admin-login-sign-in">
<External slot="authExternalAdminSignIn" />
<form onSubmit={this.handleSubmit}>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
id="email"
label="Email Address"
value={email}
onChange={this.handleEmailChange}
/>
<TextField
id="password"
label="Password"
value={password}
onChange={this.handlePasswordChange}
type="password"
/>
{requireRecaptcha && (
<div className={styles.recaptcha}>
<Recaptcha
ref={this.handleRecaptchaRef}
onVerify={this.props.onRecaptchaVerify}
/>
</div>
)}
<Button
className={cn(
styles.signInButton,
'talk-admin-login-sign-in-button'
)}
type="submit"
cStyle="black"
full
>
Request a new one.
</a>
</p>
</form>
Sign In
</Button>
<p className={styles.forgotPasswordCTA}>
{/* TODO: translate */}
Forgot your password?{' '}
<a
href="#"
className={styles.forgotPasswordLink}
onClick={this.handleForgotPasswordLink}
>
Request a new one.
</a>
</p>
</form>
</div>
);
}
}
@@ -35,44 +35,49 @@
margin-right: 20px;
}
.karmaStat {
display: flex;
}
.stat:last-child {
margin-right: 0px;
}
.statItem,
.statReportResult {
.statItem, .statReportResult, .statKarmaResult {
padding: 3px 5px;
background-color: #D8D8D8;
border-radius: 3px;
font-weight: 500;
display: block;
font-size: 0.9em;
line-height: normal;
letter-spacing: 0.4px;
min-width: 60px;
min-width: 25px;
display: block;
}
.statResult {
font-size: 1.5em;
padding: 5px 0;
display: inline-block;
text-align: center;
}
.statReportResult {
.statReportResult, .statKarmaResult {
color: white;
margin: 5px 0;
font-weight: 400;
text-align: center;
}
.statReportResult.reliable {
background-color: #749C48;
.statReportResult.reliable, .statKarmaResult.good {
background-color: #03AB61;
}
.statReportResult.neutral {
.statReportResult.neutral, .statKarmaResult.neutral {
background-color: #616161;
}
.statReportResult.unreliable {
.statReportResult.unreliable, .statKarmaResult.bad {
background-color: #F44336;
}
@@ -143,3 +148,7 @@
border-color: #E45241;
color: white;
}
.userDetailItem {
padding: 2px 0;
}
+42 -23
View File
@@ -6,11 +6,7 @@ import styles from './UserDetail.css';
import UserHistory from './UserHistory';
import { Slot } from 'coral-framework/components';
import UserDetailCommentList from '../components/UserDetailCommentList';
import {
getReliability,
isSuspended,
isBanned,
} from 'coral-framework/utils/user';
import { isSuspended, isBanned, getKarma } from 'coral-framework/utils/user';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import ClickOutside from 'coral-framework/components/ClickOutside';
import {
@@ -25,6 +21,7 @@ import {
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import UserInfoTooltip from './UserInfoTooltip';
import KarmaTooltip from './KarmaTooltip';
import t from 'coral-framework/services/i18n';
class UserDetail extends React.Component {
@@ -79,7 +76,13 @@ class UserDetail extends React.Component {
renderLoaded() {
const {
root,
root: { me, user, totalComments, rejectedComments },
root: {
me,
user,
totalComments,
rejectedComments,
settings: { karmaThresholds },
},
activeTab,
selectedCommentIds,
toggleSelect,
@@ -177,7 +180,7 @@ class UserDetail extends React.Component {
<div>
<ul className={styles.userDetailList}>
<li>
<li className={styles.userDetailItem}>
<Icon name="assignment_ind" />
<span className={styles.userDetailItem}>
{t('user_detail.member_since')}:
@@ -185,11 +188,24 @@ class UserDetail extends React.Component {
{new Date(user.created_at).toLocaleString()}
</li>
{user.profiles.map(({ id }) => (
<li key={id}>
<Icon name="email" />
<li className={styles.userDetailItem}>
<Icon name="email" />
<span className={styles.userDetailItem}>
{t('user_detail.email')}:
</span>
{user.email}{' '}
<ButtonCopyToClipboard
className={styles.copyButton}
icon="content_copy"
copyText={user.email}
/>
</li>
{user.profiles.map(({ provider, id }) => (
<li key={id} className={styles.userDetailItem}>
<Icon name="device_hub" />
<span className={styles.userDetailItem}>
{t('user_detail.email')}:
{capitalize(provider)} {t('user_detail.id')}:
</span>
{id}{' '}
<ButtonCopyToClipboard
@@ -216,18 +232,21 @@ class UserDetail extends React.Component {
{rejectedPercent.toFixed(1)}%
</span>
</li>
<li className={styles.stat}>
<span className={styles.statItem}>
{t('user_detail.reports')}
</span>
<span
className={cn(
styles.statReportResult,
styles[getReliability(user.reliable.flagger)]
)}
>
{capitalize(getReliability(user.reliable.flagger))}
</span>
<li className={cn(styles.stat, styles.karmaStat)}>
<div>
<span className={styles.statItem}>
{t('user_detail.karma')}
</span>
<span
className={cn(
styles.statKarmaResult,
styles[getKarma(user.reliable.commenter)]
)}
>
{user.reliable.commenterKarma}
</span>
</div>
<KarmaTooltip thresholds={karmaThresholds.comment} />
</li>
</ul>
</div>
@@ -12,6 +12,7 @@ import CommentAnimatedEdit from './CommentAnimatedEdit';
import CommentLabels from '../containers/CommentLabels';
import ApproveButton from './ApproveButton';
import RejectButton from 'coral-admin/src/components/RejectButton';
import CommentDeletedTombstone from './CommentDeletedTombstone';
import t, { timeago } from 'coral-framework/services/i18n';
@@ -43,6 +44,19 @@ class UserDetailComment extends React.Component {
body: comment.body,
};
if (!comment.body) {
return (
<li
tabIndex={0}
className={cn(className, styles.root, {
[styles.rootSelected]: selected,
})}
>
<CommentDeletedTombstone />
</li>
);
}
return (
<li
tabIndex={0}
@@ -152,7 +166,7 @@ UserDetailComment.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
body: PropTypes.string,
actions: PropTypes.array,
created_at: PropTypes.string.isRequired,
asset: PropTypes.shape({
@@ -12,7 +12,7 @@ import { compose } from 'react-apollo';
import t from 'coral-framework/services/i18n';
class BanUserDialogContainer extends Component {
banUser = async () => {
banUser = async ({ message }) => {
const {
userId,
commentId,
@@ -21,7 +21,7 @@ class BanUserDialogContainer extends Component {
setCommentStatus,
hideBanUserDialog,
} = this.props;
await banUser({ id: userId, message: '' });
await banUser({ id: userId, message });
hideBanUserDialog();
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
await setCommentStatus({ commentId, status: 'REJECTED' });
+4 -5
View File
@@ -2,21 +2,20 @@ import { gql } from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import Header from '../components/Header';
import CommunityIndicator from '../routes/Community/containers/Indicator';
import ModerationIndicator from '../routes/Moderation/containers/Indicator';
// TODO: eventually we will readd modqueue counts
// import ModerationIndicator from '../routes/Moderation/containers/Indicator';
import { getDefinitionName } from 'coral-framework/utils';
export default withQuery(
gql`
query TalkAdmin_Header($nullID: ID) {
...${getDefinitionName(ModerationIndicator.fragments.root)}
query TalkAdmin_Header {
...${getDefinitionName(CommunityIndicator.fragments.root)}
}
${ModerationIndicator.fragments.root}
${CommunityIndicator.fragments.root}
`,
{
options: {
variables: { nullID: null },
// variables: { nullID: null },
},
}
)(Header);
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withSignIn } from 'coral-framework/hocs';
import { withSignIn, withPopupAuthHandler } from 'coral-framework/hocs';
import { compose } from 'recompose';
import SignIn from '../components/SignIn';
@@ -55,4 +55,4 @@ SignInContainer.propTypes = {
requireRecaptcha: PropTypes.bool.isRequired,
};
export default compose(withSignIn)(SignInContainer);
export default compose(withSignIn, withPopupAuthHandler)(SignInContainer);
@@ -179,12 +179,14 @@ export const withUserDetailQuery = withQuery(
id
username
created_at
email
profiles {
id
provider
}
reliable {
flagger
commenter
commenterKarma
}
state {
status {
@@ -225,6 +227,14 @@ export const withUserDetailQuery = withQuery(
}
${getSlotFragmentSpreads(slots, 'user')}
}
settings {
karmaThresholds {
comment {
reliable
unreliable
}
}
}
me {
id
}
+51 -2
View File
@@ -24,6 +24,25 @@ const userRoleFragment = gql`
}
`;
/**
* calculateReliability will determine the reliability of a karma score based on
* the settings for the karma type.
*
* @param {Number} karma - the current karma value/score for the given user
* @param {Object} thresholds - the karma thresholds to base the karma computation on
*/
const calculateReliability = (karma, { reliable, unreliable }) => {
if (karma >= reliable) {
return true;
}
if (karma <= unreliable) {
return false;
}
return null;
};
export default {
mutations: {
SetUserRole: ({ variables: { id, role } }) => ({
@@ -156,7 +175,9 @@ export default {
}
const updated = update(prev, {
users: {
nodes: { $apply: nodes => nodes.filter(node => node.id !== id) },
nodes: {
$apply: nodes => nodes.filter(node => node.id !== id),
},
},
});
return updated;
@@ -185,7 +206,9 @@ export default {
const updated = update(prev, {
...decrement,
flaggedUsers: {
nodes: { $apply: nodes => nodes.filter(node => node.id !== id) },
nodes: {
$apply: nodes => nodes.filter(node => node.id !== id),
},
},
});
return updated;
@@ -295,12 +318,38 @@ export default {
updateQueries: {
CoralAdmin_UserDetail: prev => {
const increment = {
user: {
reliable: {
commenter: {
$set: calculateReliability(
prev.user.reliable.commenterKarma - 1,
prev.settings.karmaThresholds.comment
),
},
commenterKarma: {
$apply: count => count - 1,
},
},
},
rejectedComments: {
$apply: count => (count < prev.totalComments ? count + 1 : count),
},
};
const decrement = {
user: {
reliable: {
commenter: {
$set: calculateReliability(
prev.user.reliable.commenterKarma + 1,
prev.settings.karmaThresholds.comment
),
},
commenterKarma: {
$apply: count => count + 1,
},
},
},
rejectedComments: {
$apply: count => (count > 0 ? count - 1 : 0),
},
@@ -133,6 +133,7 @@ th.header:nth-child(2), th.header:nth-child(3) {
.roleDropdown {
width: 150px;
text-align: left;
}
.roleOption {
@@ -130,7 +130,9 @@ class People extends React.Component {
{user.username}
</button>
<span className={styles.email}>
{user.profiles.map(({ id }) => id)}
{user.email
? user.email
: user.profiles.map(p => p.id).join(', ')}
</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
@@ -200,7 +202,7 @@ class People extends React.Component {
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
className={cn(
toggleClassName={cn(
'talk-admin-community-people-dd-role',
styles.roleDropdown
)}
@@ -82,6 +82,20 @@ class StreamSettings extends React.Component {
this.props.updatePending({ updater });
};
updateDisableCommenting = () => {
const updater = {
disableCommenting: {
$set: !this.props.settings.disableCommenting,
},
};
this.props.updatePending({ updater });
};
updateDisableCommentingMessage = value => {
const updater = { disableCommentingMessage: { $set: value } };
this.props.updatePending({ updater });
};
updateAutoClose = () => {
const updater = {
autoCloseStream: { $set: !this.props.settings.autoCloseStream },
@@ -192,6 +206,25 @@ class StreamSettings extends React.Component {
&nbsp;
{t('configure.edit_comment_timeframe_text_post')}
</ConfigureCard>
<ConfigureCard
checked={settings.disableCommenting}
onCheckbox={this.updateDisableCommenting}
title={t('configure.disable_commenting_title')}
>
<p>{t('configure.disable_commenting_desc')}</p>
<div
className={cn(
styles.configSettingDisableCommenting,
settings.disableCommenting ? null : styles.hidden
)}
>
<MarkdownEditor
className={styles.descriptionBox}
onChange={this.updateDisableCommentingMessage}
value={settings.disableCommentingMessage}
/>
</div>
</ConfigureCard>
<ConfigureCard
checked={settings.autoCloseStream}
onCheckbox={this.updateAutoClose}
@@ -39,6 +39,8 @@ export default compose(
autoCloseStream
closedTimeout
closedMessage
disableCommenting
disableCommentingMessage
${getSlotFragmentSpreads(slots, 'settings')}
}
`,
@@ -1,5 +1,4 @@
@custom-media --big-viewport (min-width: 780px);
.root {
border-bottom: 1px solid #e0e0e0;
font-size: 18px;
@@ -13,7 +12,6 @@
margin-top: 13px;
min-height: 0;
outline: 0;
/*
Fix rendering issues in Safari by promoting this
into its own layer.
@@ -21,7 +19,6 @@
https://www.pivotaltracker.com/story/show/151142211
*/
transform: translateZ(0);
&:last-child {
border-bottom: none;
}
@@ -39,7 +36,6 @@
display: flex;
align-items: center;
justify-content: space-between;
}
.author {
@@ -74,7 +70,7 @@
max-width: 500px;
font-weight: 300;
font-size: 16px;
word-break: break-all;
overflow-wrap: break-word;
}
.created {
@@ -85,13 +81,16 @@
font-weight: 300;
}
.deleted {
background-color: #f0f0f0;
}
.moderateArticle {
font-size: 14px;
margin: 10px 0;
font-weight: 500;
line-height: 1.2;
max-width: 500px;
a {
display: inline-block;
color: #063b9a;
@@ -99,17 +98,15 @@
font-weight: 500;
letter-spacing: .5px;
margin-left: 10px;
font-size: 13px;
margin-left: 5px;
padding-bottom: 0px;
border-bottom: solid 1px;
line-height: 16px;
&:hover {
opacity: .9;
cursor: pointer;
}
}
}
}
@@ -134,12 +131,10 @@
cursor: pointer;
font-weight: normal;
white-space: nowrap;
&:hover {
text-decoration: underline;
opacity: .9;
}
i {
font-size: 12px;
position: relative;
@@ -168,7 +163,6 @@
align-items: center;
justify-content: flex-end;
margin-right: 14px;
i {
margin-right: 5px;
}
@@ -177,7 +171,6 @@
@media (--big-viewport) {
.root {
margin-bottom: 30px;
&:last-child {
border-bottom: 1px solid #e0e0e0;
}
@@ -186,7 +179,6 @@
.commentContent {
display: flex;
blockquote {
font-size: inherit;
line-height: inherit;
@@ -13,6 +13,7 @@ import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import ApproveButton from 'coral-admin/src/components/ApproveButton';
import RejectButton from 'coral-admin/src/components/RejectButton';
import CommentDeletedTombstone from '../../../components/CommentDeletedTombstone';
import t, { timeago } from 'coral-framework/services/i18n';
@@ -75,6 +76,27 @@ class Comment extends React.Component {
asset: comment.asset,
};
if (!comment.body) {
return (
<li
tabIndex={0}
className={cn(
className,
'mdl-card',
selectionStateCSS,
styles.root,
{ [styles.selected]: selected, [styles.dangling]: dangling },
'talk-admin-moderate-comment',
styles.deleted
)}
id={`comment_${comment.id}`}
ref={this.handleRef}
>
<CommentDeletedTombstone />
</li>
);
}
return (
<li
tabIndex={0}
@@ -200,7 +222,7 @@ Comment.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
body: PropTypes.string,
action_summaries: PropTypes.array,
actions: PropTypes.array,
created_at: PropTypes.string.isRequired,
@@ -63,10 +63,6 @@ class Moderation extends Component {
this.props.toggleStorySearch(true);
};
getActiveTabCount = (props = this.props) => {
return props.root[`${props.activeTab}Count`];
};
moderate = accept => {
const {
acceptComment,
@@ -139,12 +135,14 @@ class Moderation extends Component {
const comments = root[activeTab];
const activeTabCount = this.getActiveTabCount();
const menuItems = Object.keys(queueConfig).map(queue => ({
key: queue,
name: queueConfig[queue].name,
icon: queueConfig[queue].icon,
count: root[`${queue}Count`],
indicator:
['premod', 'reported'].includes(queue) && root[queue].nodes.length > 0,
// TODO: Eventually we'll reintroduce counting
// count: root[`${props.queue}Count`]
}));
const slotPassthrough = {
@@ -189,7 +187,6 @@ class Moderation extends Component {
loadMore={this.loadMore}
commentBelongToQueue={this.props.commentBelongToQueue}
isLoadingMore={this.state.isLoadingMore}
commentCount={activeTabCount}
currentUserId={this.props.currentUser.id}
viewUserDetail={viewUserDetail}
selectCommentId={props.selectCommentId}
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import CountBadge from '../../../components/CountBadge';
import Indicator from '../../../components/Indicator';
import styles from './ModerationMenu.css';
import { Icon } from 'coral-ui';
import { Link } from 'react-router';
@@ -24,6 +24,7 @@ const ModerationMenu = ({ asset = {}, items, getModPath, activeTab }) => {
>
{items.map(queue => (
<Link
id={`talk-admin-moderate-tab-${queue.key}`}
key={queue.key}
to={getModPath(queue.key, asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {
@@ -32,7 +33,7 @@ const ModerationMenu = ({ asset = {}, items, getModPath, activeTab }) => {
activeClassName={styles.active}
>
<Icon name={queue.icon} className={styles.tabIcon} /> {queue.name}{' '}
<CountBadge count={queue.count} />
{queue.indicator && <Indicator />}
</Link>
))}
</div>
@@ -15,6 +15,7 @@ import {
} from 'react-virtualized';
import throttle from 'lodash/throttle';
import key from 'keymaster';
import cn from 'classnames';
const hasComment = (nodes, id) => nodes.some(node => node.id === id);
@@ -204,7 +205,7 @@ class ModerationQueue extends React.Component {
}
componentDidUpdate(prev) {
const { commentCount, selectedCommentId } = this.props;
const { selectedCommentId, hasNextPage } = this.props;
const switchedToMultiMode = prev.singleView && !this.props.singleView;
const switchedMode = prev.singleView !== this.props.singleView;
@@ -212,7 +213,6 @@ class ModerationQueue extends React.Component {
prev.selectedCommentId !== selectedCommentId && selectedCommentId;
const moderatedLastComment =
prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0;
const hasMoreComment = commentCount > 0;
if (switchedToMultiMode) {
// Reflow virtual list.
@@ -223,7 +223,7 @@ class ModerationQueue extends React.Component {
this.scrollToSelectedComment();
}
if (moderatedLastComment && hasMoreComment) {
if (moderatedLastComment && hasNextPage) {
this.props.loadMore();
}
}
@@ -240,10 +240,7 @@ class ModerationQueue extends React.Component {
const index = view.findIndex(
({ id }) => id === this.props.selectedCommentId
);
if (
index === view.length - 1 &&
this.getCommentCountWithoutDagling() !== this.props.commentCount
) {
if (index === view.length - 1 && this.props.hasNextPage) {
await this.props.loadMore();
this.selectDown();
return;
@@ -384,6 +381,11 @@ class ModerationQueue extends React.Component {
...props
} = this.props;
const rootClassName = cn(
styles.root,
`talk-admin-moderate-queue-${this.props.activeTab}`
);
if (comments.length === 0) {
return (
<div className={styles.root}>
@@ -405,7 +407,7 @@ class ModerationQueue extends React.Component {
const comment = comments[index];
return (
<div className={styles.root}>
<div className={rootClassName}>
<Comment
root={this.props.root}
key={comment.id}
@@ -427,7 +429,7 @@ class ModerationQueue extends React.Component {
const view = this.state.view;
return (
<div className={styles.root}>
<div className={rootClassName}>
<ViewMore
viewMore={() => this.viewNewComments()}
count={comments.length - view.length}
@@ -467,7 +469,6 @@ ModerationQueue.propTypes = {
acceptComment: PropTypes.func.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
commentCount: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
singleView: PropTypes.bool,
isLoadingMore: PropTypes.bool,
@@ -314,11 +314,11 @@ class ModerationContainer extends Component {
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
if (premodEnabled && root.newCount === 0) {
if (premodEnabled && root.new.nodes.length === 0) {
delete currentQueueConfig.new;
}
if (!premodEnabled && root.premodCount === 0) {
if (!premodEnabled && root.premod.nodes.length === 0) {
delete currentQueueConfig.premod;
}
@@ -402,7 +402,7 @@ const COMMENT_RESET_SUBSCRIPTION = gql`
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $tags:[String!], $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags}) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags, excludeDeleted: true}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
@@ -432,6 +432,7 @@ const withModQueueQuery = withQuery(
${Object.keys(queueConfig).map(
queue => `
${queue}: comments(query: {
excludeDeleted: true,
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
@@ -455,9 +456,14 @@ const withModQueueQuery = withQuery(
}
`
)}
${Object.keys(queueConfig).map(
${
''
/*
TODO: eventually we'll reintroduce counting..
Object.keys(queueConfig).map(
queue => `
${queue}Count: commentCount(query: {
excludeDeleted: true,
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
@@ -476,7 +482,8 @@ const withModQueueQuery = withQuery(
asset_id: $asset_id,
})
`
)}
)*/
}
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
@@ -92,9 +92,6 @@
.statusDropdown {
width: 150px;
}
.statusDropdownOption {
min-width: 100px;
text-align: left;
}
@@ -22,20 +22,12 @@ class Stories extends Component {
const closed = !!(closedAt && new Date(closedAt).getTime() < Date.now());
return (
<Dropdown
className={styles.statusDropdown}
toggleClassName={styles.statusDropdown}
value={closed}
onChange={value => this.props.onStatusChange(value, id)}
>
<Option
value={false}
label={t('streams.open')}
className={styles.statusDropdownOption}
/>
<Option
value={true}
label={t('streams.closed')}
className={styles.statusDropdownOption}
/>
<Option value={false} label={t('streams.open')} />
<Option value={true} label={t('streams.closed')} />
</Dropdown>
);
};
+27 -23
View File
@@ -3,32 +3,36 @@ import { getStaticConfiguration } from 'coral-framework/services/staticConfigura
import { createPostMessage } from 'coral-framework/services/postMessage';
document.addEventListener('DOMContentLoaded', () => {
try {
const staticConfig = getStaticConfiguration();
const { STATIC_ORIGIN: origin } = staticConfig;
const postMessage = createPostMessage(origin);
const staticConfig = getStaticConfiguration();
const { STATIC_ORIGIN: origin } = staticConfig;
const postMessage = createPostMessage(origin);
// Get the auth element and parse it as JSON by decoding it.
const auth = document.getElementById('auth');
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = auth.innerText;
// Get the auth element and parse it as JSON by decoding it.
const auth = document.getElementById('auth');
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = auth.innerText;
// Auth state is contained within the node.
const { err, data } = JSON.parse(doc.body.textContent);
if (err) {
// TODO: send back the error message.
console.error(err);
// Auth state is contained within the node.
const { err, data } = JSON.parse(doc.body.textContent);
if (err) {
const errDiv = document.createElement('div');
if (err.message) {
errDiv.innerText = `${err.name}: ${err.message}`;
} else {
// The data will contain a user and a token.
const { user, token } = data;
// Send the state back.
postMessage.post(HANDLE_SUCCESSFUL_LOGIN, { user, token });
errDiv.innerText = JSON.stringify(err);
}
} finally {
// Always close the window.
setTimeout(() => {
window.close();
}, 50);
document.body.appendChild(errDiv);
throw err;
}
// The data will contain a user and a token.
const { user, token } = data;
// Send the state back.
postMessage.post(HANDLE_SUCCESSFUL_LOGIN, { user, token });
// Close the window when all went well.
setTimeout(() => {
window.close();
}, 50);
});
+8
View File
@@ -8,6 +8,14 @@ import reducers from './reducers';
import TalkProvider from 'coral-framework/components/TalkProvider';
import pluginsConfig from 'pluginsConfig';
// Resolves touch handling issues encountered on IOS Safari under certain
// circumstances. It may be related to issues reported here:
//
// https://stackoverflow.com/questions/12363742/touchstart-event-is-not-firing-inside-iframe-ios-6
//
// Further details: https://www.pivotaltracker.com/story/show/157794038
document.body.addEventListener('touchstart', () => {});
async function main() {
const context = await createContext({
reducers,
@@ -31,6 +31,7 @@
font-weight: bold;
font-size: 12px;
color: #757575;
cursor: pointer;
}
.commentSummary {
@@ -11,16 +11,6 @@ import { getTotalReactionsCount } from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
class Comment extends React.Component {
goToStory = () => {
this.props.navigate(this.props.comment.asset.url);
};
goToConversation = () => {
this.props.navigate(
`${this.props.comment.asset.url}?commentId=${this.props.comment.id}`
);
};
render() {
const { comment, root } = this.props;
const reactionCount = getTotalReactionsCount(comment.action_summaries);
@@ -76,8 +66,8 @@ class Comment extends React.Component {
<div className="my-comment-asset">
<a
className={cn(styles.assetURL, 'my-comment-anchor')}
href="#"
onClick={this.goToStory}
href={this.props.comment.asset.url}
target="_parent"
>
{t('common.story')}:{' '}
{comment.asset.title ? comment.asset.title : comment.asset.url}
@@ -87,7 +77,13 @@ class Comment extends React.Component {
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={this.goToConversation} className={styles.viewLink}>
<a
className={styles.viewLink}
href={`${this.props.comment.asset.url}?commentId=${
this.props.comment.id
}`}
target="_parent"
>
<Icon name="open_in_new" className={styles.iconView} />
{t('view_conversation')}
</a>
@@ -214,7 +214,7 @@ AllCommentsPane.propTypes = {
asset: PropTypes.object,
currentUser: PropTypes.object,
postFlag: PropTypes.func,
postDontAgree: PropTypes.func,
postDontAgree: PropTypes.func.isRequired,
loadNewReplies: PropTypes.func,
deleteAction: PropTypes.func,
showSignInDialog: PropTypes.func,
@@ -184,7 +184,7 @@ export default class Comment extends React.Component {
maxCharCount: PropTypes.number,
root: PropTypes.object,
loadMore: PropTypes.func,
postDontAgree: PropTypes.func,
postDontAgree: PropTypes.func.isRequired,
animateEnter: PropTypes.bool,
commentClassNames: PropTypes.array,
comment: PropTypes.object.isRequired,
@@ -410,6 +410,7 @@ export default class Comment extends React.Component {
charCountEnable,
showSignInDialog,
liveUpdates,
postDontAgree,
emit,
} = this.props;
return (
@@ -440,6 +441,7 @@ export default class Comment extends React.Component {
key={reply.id}
comment={reply}
emit={emit}
postDontAgree={postDontAgree}
/>
);
})}
@@ -743,10 +745,21 @@ export default class Comment extends React.Component {
const id = `c_${comment.id}`;
// props that are passed down the slots.
const slotPassthrough = {
action: 'deleted',
comment,
};
return (
<div className={rootClassName} id={id}>
{isCommentDeleted(comment) ? (
<CommentTombstone action="deleted" />
<Slot
fill="commentTombstone"
defaultComponent={CommentTombstone}
size={1}
passthrough={slotPassthrough}
/>
) : (
<div>
{this.renderComment()}
@@ -39,6 +39,7 @@ class CommentTombstone extends React.Component {
CommentTombstone.propTypes = {
action: PropTypes.string,
comment: PropTypes.object,
onUndo: PropTypes.func,
};
@@ -4,6 +4,7 @@ import StreamError from './StreamError';
import Comment from '../containers/Comment';
import BannedAccount from '../../../components/BannedAccount';
import ChangeUsername from '../containers/ChangeUsername';
import Markdown from 'coral-framework/components/Markdown';
import Slot from 'coral-framework/components/Slot';
import InfoBox from './InfoBox';
import { can } from 'coral-framework/services/perms';
@@ -181,7 +182,9 @@ class Stream extends React.Component {
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
disableReply={asset.isClosed}
disableReply={
asset.isClosed || asset.settings.disableCommenting
}
postComment={postComment}
currentUser={currentUser}
postFlag={postFlag}
@@ -215,7 +218,7 @@ class Stream extends React.Component {
currentUser,
} = this.props;
const { keepCommentBox } = this.state;
const open = !asset.isClosed;
const open = !(asset.isClosed || asset.settings.disableCommenting);
const banned = get(currentUser, 'status.banned.status');
const suspensionUntil = get(currentUser, 'status.suspension.until');
@@ -293,7 +296,13 @@ class Stream extends React.Component {
)}
</div>
) : (
<p>{asset.settings.closedMessage}</p>
<div>
{asset.isClosed ? (
<p>{asset.settings.closedMessage}</p>
) : (
<Markdown content={asset.settings.disableCommentingMessage} />
)}
</div>
)}
<Slot fill="stream" passthrough={slotPassthrough} />
@@ -24,6 +24,7 @@ const slots = [
'commentAuthorName',
'commentAuthorTags',
'commentTimestamp',
'commentTombstone',
'commentContent',
];
@@ -265,7 +265,7 @@ StreamContainer.propTypes = {
commentClassNames: PropTypes.array,
setActiveStreamTab: PropTypes.func,
postFlag: PropTypes.func,
postDontAgree: PropTypes.func,
postDontAgree: PropTypes.func.isRequired,
deleteAction: PropTypes.func,
showSignInDialog: PropTypes.func,
currentUser: PropTypes.object,
@@ -434,6 +434,8 @@ const fragments = {
questionBoxIcon
closedTimeout
closedMessage
disableCommenting
disableCommentingMessage
charCountEnable
charCount
requireEmailConfirmation
+11 -5
View File
@@ -37,7 +37,8 @@ export default class Popup extends Component {
this.onBlur();
};
// Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari.
// Use `onunload` instead of `onbeforeunload` which is not supported in iOS
// Safari.
this.ref.onunload = () => {
this.onUnload();
@@ -46,10 +47,15 @@ export default class Popup extends Component {
}
this.resetCallbackInterval = setInterval(() => {
if (this.ref && this.ref.onload === null) {
clearInterval(this.resetCallbackInterval);
this.resetCallbackInterval = null;
this.setCallbacks();
try {
if (this.ref && this.ref.onload === null) {
clearInterval(this.resetCallbackInterval);
this.resetCallbackInterval = null;
this.setCallbacks();
}
} catch (err) {
// We could be getting a security exception here if the login page
// gets redirected to another domain to authenticate.
}
}, 50);
@@ -1,5 +1,5 @@
import React from 'react';
const PropTypes = require('prop-types');
import PropTypes from 'prop-types';
import { ApolloProvider } from 'react-apollo';
class TalkProvider extends React.Component {
+1 -1
View File
@@ -116,7 +116,7 @@ export const withRemoveTag = withMutation(
asset_id: assetId,
item_type: itemType,
},
o3timisticResponse: {
optimisticResponse: {
removeTag: {
__typename: 'ModifyTagResponse',
errors: null,
@@ -1,9 +1,9 @@
/* global __webpack_public_path__ */ // eslint-disable-line no-unused-vars
/* global __webpack_public_path__, __webpack_nonce__ */ // eslint-disable-line no-unused-vars
import { getStaticConfiguration } from 'coral-framework/services/staticConfiguration';
// Load the static url from the static configuration.
const { STATIC_URL } = getStaticConfiguration();
const { STATIC_URL, SCRIPT_NONCE } = getStaticConfiguration();
// Update the static url for the imported public path so dynamically imported
// chunks will use the correct path as defined by the process.env.STATIC_URL
@@ -14,3 +14,13 @@ const { STATIC_URL } = getStaticConfiguration();
// https://webpack.js.org/configuration/output/#output-publicpath
//
__webpack_public_path__ = STATIC_URL + 'static/';
// All dynamically included scripts that support nonce's will add this to their
// script tags.
//
// The __webpack_nonce__ can be referenced: https://webpack.js.org/guides/csp/
//
// Pending issues:
// - https://github.com/webpack-contrib/style-loader/pull/319
//
__webpack_nonce__ = SCRIPT_NONCE;
@@ -59,6 +59,7 @@ const withSetUsername = hoistStatics(WrappedComponent => {
}
const changeSet = { success: false, loading: false, error };
this.setState(changeSet);
throw error;
}
};
+74 -74
View File
@@ -1,7 +1,10 @@
import ta from 'timeago.js';
import { negotiateLanguages } from 'fluent-langneg/compat';
import has from 'lodash/has';
import get from 'lodash/get';
import merge from 'lodash/merge';
import first from 'lodash/first';
import isUndefined from 'lodash/isUndefined';
import moment from 'moment';
import 'moment/locale/ar';
@@ -12,8 +15,8 @@ import 'moment/locale/fr';
import 'moment/locale/nl';
import 'moment/locale/pt-br';
import { createStorage } from 'coral-framework/services/storage';
// timeago
import ta from 'timeago.js';
import arTA from 'timeago.js/locales/ar';
import daTA from 'timeago.js/locales/da';
import deTA from 'timeago.js/locales/de';
@@ -24,6 +27,7 @@ import pt_BRTA from 'timeago.js/locales/pt_BR';
import zh_CNTA from 'timeago.js/locales/zh_CN';
import zh_TWTA from 'timeago.js/locales/zh_TW';
// locales
import ar from '../../../locales/ar.yml';
import en from '../../../locales/en.yml';
import da from '../../../locales/da.yml';
@@ -35,8 +39,22 @@ import pt_BR from '../../../locales/pt_BR.yml';
import zh_CN from '../../../locales/zh_CN.yml';
import zh_TW from '../../../locales/zh_TW.yml';
const defaultLanguage = process.env.TALK_DEFAULT_LANG;
const translations = {
// the list of languages that are whitelisted. If false, all languages that are
// supported by Talk will be enabled.
const whitelistedLanguages =
process.env.TALK_WHITELISTED_LANGUAGES &&
process.env.TALK_WHITELISTED_LANGUAGES.split(',').map(l => l.trim());
// The default language. If the whitelisted languages is specified and the
// default language is not in that list, then the first language in the
// whitelisted list will be used as the default.
export const defaultLocale = whitelistedLanguages
? !whitelistedLanguages.includes(process.env.TALK_DEFAULT_LANG)
? whitelistedLanguages[0]
: process.env.TALK_DEFAULT_LANG
: process.env.TALK_DEFAULT_LANG;
export const translations = {
...ar,
...en,
...da,
@@ -49,84 +67,66 @@ const translations = {
...zh_TW,
};
let lang;
let timeagoInstance;
export const supportedLocales = Object.keys(translations);
function setLocale(storage, locale) {
storage.setItem('locale', locale);
}
let LOCALE;
let TIMEAGO_INSTANCE;
// detectLanguage will try to get the locale from storage if available,
// otherwise will try to get it from the navigator, otherwise, it will fallback
// to the default language.
function detectLanguage(storage) {
try {
const lang = storage.getItem('locale') || navigator.language;
if (lang) {
return lang;
}
} catch (err) {
console.warn(
'Error while trying to detect language, will fallback to',
err
);
}
console.warn('Could not detect language, will fallback to', defaultLanguage);
return defaultLanguage;
}
// getLocale will get the users locale from the local detector and parse it to a
// format we can work with.
function getLocale(storage) {
// Get the language from the local detector.
const lang = detectLanguage(storage);
// Some language strings come with additional subtags as defined in:
//
// https://www.ietf.org/rfc/bcp/bcp47.txt
//
// So we should strip that off if we find it.
return lang.split('-')[0];
}
const detectLanguage = () =>
first(
negotiateLanguages(
navigator.languages,
whitelistedLanguages || supportedLocales,
{
defaultLocale,
strategy: 'lookup',
}
)
);
export function setupTranslations() {
// Setup the translation framework with the storage.
const storage = createStorage('localStorage');
// locale
LOCALE = detectLanguage();
const locale = getLocale(storage);
setLocale(storage, locale);
// Setting moment
moment.locale(locale);
// Extract language key.
lang = locale.split('-')[0];
// Check if we have a translation in this language.
if (!(lang in translations)) {
lang = defaultLanguage;
}
// moment
moment.locale(LOCALE);
// timeago
ta.register('ar', arTA);
ta.register('es', esTA);
ta.register('da', daTA);
ta.register('de', deTA);
ta.register('fr', frTA);
ta.register('nl_NL', nlTA);
ta.register('pt_BR', pt_BRTA);
ta.register('zh_CN', zh_CNTA);
ta.register('zh_TW', zh_TWTA);
timeagoInstance = ta();
ta.register('nl-NL', nlTA);
ta.register('pt-BR', pt_BRTA);
ta.register('zh-CN', zh_CNTA);
ta.register('zh-TW', zh_TWTA);
TIMEAGO_INSTANCE = ta();
}
/**
* loadTranslations will load the new language pack into the existing ones.
*
* @param {Object} newTranslations translation object to merge into the existing
* languages.
*/
export function loadTranslations(newTranslations) {
// Merge the new translations into the existing translations.
merge(translations, newTranslations);
// Push new languages into the supportedLocales array.
Object.keys(newTranslations).forEach(language => {
if (!supportedLocales.includes(language)) {
supportedLocales.push(language);
}
});
}
export function timeago(time) {
return timeagoInstance.format(new Date(time), lang);
return TIMEAGO_INSTANCE.format(new Date(time), LOCALE);
}
/**
@@ -140,24 +140,24 @@ export function timeago(time) {
*/
export function t(key, ...replacements) {
let translation;
if (has(translations[lang], key)) {
translation = get(translations[lang], key);
if (has(translations[LOCALE], key)) {
translation = get(translations[LOCALE], key);
} else if (has(translations['en'], key)) {
translation = get(translations['en'], key);
console.warn(`${lang}.${key} language key not set`);
console.warn(`${LOCALE}.${key} language key not set`);
}
if (translation) {
// replace any {n} with the arguments passed to this method
replacements.forEach((str, i) => {
translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str);
});
return translation;
} else {
console.warn(`${lang}.${key} and en.${key} language key not set`);
if (!translation) {
console.warn(`${LOCALE}.${key} and en.${key} language key not set`);
return key;
}
// Handle replacements in the translation string.
return translation.replace(
/{(\d+)}/g,
(match, number) =>
!isUndefined(replacements[number]) ? replacements[number] : match
);
}
export default t;
@@ -56,10 +56,10 @@ export function createPostMessage(origin, scope = 'client') {
// Send the message.
target.postMessage(msg, origin);
},
subscribe: (handler, target = window) => {
subscribe(handler, target = window) {
// If this handler is already attached to the target, detach it.
if (has(listeners, [target, handler])) {
this.unsubscribeFromMessages(handler, target);
this.unsubscribe(handler, target);
}
// Wrap the listener with a origin check.
@@ -71,7 +71,7 @@ export function createPostMessage(origin, scope = 'client') {
// Attach the listener to the target.
target.addEventListener('message', listener);
},
unsubscribe: (handler, target = window) => {
unsubscribe(handler, target = window) {
if (!has(listeners, [target, handler])) {
return;
}
+1
View File
@@ -6,6 +6,7 @@
border: none;
touch-action: manipulation;
padding: 0;
margin: 0;
overflow: hidden;
+20
View File
@@ -273,3 +273,23 @@ export function translateError(error) {
}
return error.toString();
}
/**
* handlePopupAuth will optionally open a popup with the requested uri if the
* window is not already a popup.
*
* @param {String} uri the url to open the window? to
* @param {String} title the title of the new window? to open
* @param {String} features the features to use when opening a window?
*/
export function handlePopupAuth(
uri,
title = 'Login', // TODO: translate
features = 'menubar=0,resizable=0,width=500,height=550,top=200,left=500'
) {
if (window.opener) {
window.location = uri;
} else {
window.open(uri, title, features);
}
}
+15
View File
@@ -49,3 +49,18 @@ export const canUsernameBeUpdated = status => {
moment(created_at).isAfter(oldestEditTime)
);
};
/**
* getKarma
* retrieves karma value as string
*/
export const getKarma = reliability => {
if (reliability === null) {
return 'neutral';
} else if (reliability) {
return 'good';
} else {
return 'bad';
}
};
+17 -8
View File
@@ -7,17 +7,26 @@ import cn from 'classnames';
* BareButton is a button whose styling is stripped off to a minimum.
* Can pass anchor=true to use `a` instead of `button`
*/
const BareButton = ({ anchor, className, ...props }) => {
let Element = 'button';
if (anchor) {
Element = 'a';
export default class BareButton extends React.Component {
ref = null;
handleRef = ref => (this.ref = ref);
focus = () => this.ref.focus();
render() {
const { anchor, className, ...props } = this.props;
const Element = anchor ? 'a' : 'button';
return (
<Element
{...props}
className={cn(styles.bare, className)}
ref={this.handleRef}
/>
);
}
return <Element {...props} className={cn(styles.bare, className)} />;
};
}
BareButton.propTypes = {
className: PropTypes.string,
anchor: PropTypes.bool,
};
export default BareButton;
+13 -18
View File
@@ -1,32 +1,27 @@
.dropdown {
display: inline-block;
position: relative;
height: 34px;
background: #2c2c2c;
box-sizing: border-box;
color: white;
border-radius: 3px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
line-height: 20px;
font-size: 0.98em;
border-radius: 3px;
cursor: pointer;
&.disabled {
color: #e5e5e5;
background: #888;
cursor: default;
pointer-events: none;
}
}
.toggle {
padding: 8px 45px 8px 15px;
outline: none;
color: white;
background: #2c2c2c;
border-radius: 3px;
height: 34px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
line-height: 20px;
font-size: 0.98em;
&:focus {
background: #888;
}
&:disabled {
color: #e5e5e5;
background: #888;
}
}
.toggleOpen {
+5 -19
View File
@@ -4,6 +4,7 @@ import styles from './Dropdown.css';
import Icon from './Icon';
import cn from 'classnames';
import ClickOutside from 'coral-framework/components/ClickOutside';
import { BareButton } from 'coral-ui';
class Dropdown extends React.Component {
toggleRef = null;
@@ -88,16 +89,6 @@ class Dropdown extends React.Component {
this.toggle();
};
handleKeyDown = e => {
const code = e.which;
// 13 = Return, 32 = Space
if (code === 13 || code === 32) {
e.preventDefault();
this.toggle();
}
};
hideMenu = () => {
this.setState({
isOpen: false,
@@ -155,23 +146,18 @@ class Dropdown extends React.Component {
styles.dropdown,
className,
containerClassName,
'dd dd-container',
{
[styles.disabled]: disabled,
}
'dd dd-container'
)}
>
<div
<BareButton
className={cn(styles.toggle, toggleClassName, {
[cn(this.state.isOpen, toggleOpenClassName)]: this.state.isOpen,
})}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
role="button"
aria-pressed={this.state.isOpen}
aria-haspopup="true"
tabIndex={disabled ? '-1' : '0'}
ref={this.handleToggleRef}
disabled={disabled}
>
{this.props.icon && (
<Icon
@@ -194,7 +180,7 @@ class Dropdown extends React.Component {
aria-hidden="true"
/>
)}
</div>
</BareButton>
{this.state.isOpen && (
<div>
<div tabIndex="0" onFocus={this.trapFocusBegin} />
+3
View File
@@ -1,7 +1,10 @@
.option {
min-width: 100px;
width: 100%;
padding: 10px;
outline: none;
white-space: nowrap;
text-align: left;
&:focus, &:hover {
background-color: #ccc;
+14 -11
View File
@@ -1,13 +1,15 @@
import React from 'react';
import { findDOMNode } from 'react-dom';
import PropTypes from 'prop-types';
import styles from './Option.css';
import cn from 'classnames';
import { BareButton } from 'coral-ui';
class Option extends React.Component {
ref = null;
handleRef = ref => {
this.ref = ref;
this.ref = findDOMNode(ref);
};
focus = () => {
@@ -19,16 +21,17 @@ class Option extends React.Component {
const { className, label = '', onClick, onKeyDown } = this.props;
const id = this.props.id ? this.props.id : this.props.value;
return (
<li
className={cn(styles.option, className, 'dd-option')}
onClick={onClick}
onKeyDown={onKeyDown}
role="option"
tabIndex="0"
ref={this.handleRef}
id={id}
>
{label}
<li>
<BareButton
className={cn(styles.option, className, 'dd-option')}
onClick={onClick}
onKeyDown={onKeyDown}
role="option"
ref={this.handleRef}
id={id}
>
{label}
</BareButton>
</li>
);
}