Merge branch 'master' into neutral-reactions-icon

This commit is contained in:
Kim Gardner
2018-06-14 14:03:52 +01:00
committed by GitHub
351 changed files with 11156 additions and 8526 deletions
+5
View File
@@ -2,5 +2,10 @@
"env": {
"jest": true
},
"settings": {
"react": {
"version": "15.0"
}
},
"extends": "@coralproject/eslint-config-talk"
}
+2 -1
View File
@@ -7,6 +7,7 @@
"https://nodesecurity.io/advisories/594",
"https://nodesecurity.io/advisories/603",
"https://nodesecurity.io/advisories/611",
"https://nodesecurity.io/advisories/612"
"https://nodesecurity.io/advisories/612",
"https://nodesecurity.io/advisories/654"
]
}
+2
View File
@@ -0,0 +1,2 @@
8
+4
View File
@@ -0,0 +1,4 @@
overrides:
- files: "bin/cli*"
options:
parser: babylon
+2 -1
View File
@@ -7,6 +7,7 @@ ONBUILD ARG TALK_REPLY_COMMENTS_LOAD_DEPTH=3
ONBUILD ARG TALK_THREADING_LEVEL=3
ONBUILD ARG TALK_DEFAULT_STREAM_TAB=all
ONBUILD ARG TALK_DEFAULT_LANG=en
ONBUILD ARG TALK_WHITELISTED_LANGUAGES
ONBUILD ARG TALK_PLUGINS_JSON
ONBUILD ARG TALK_WEBPACK_SOURCE_MAP
@@ -20,4 +21,4 @@ ONBUILD COPY . /usr/src/app
ONBUILD RUN cli plugins reconcile && \
yarn && \
yarn build && \
yarn cache clean
yarn cache clean
+22 -1
View File
@@ -1,4 +1,6 @@
const express = require('express');
const nunjucks = require('nunjucks');
const cons = require('consolidate');
const trace = require('./middleware/trace');
const logging = require('./middleware/logging');
const path = require('path');
@@ -72,7 +74,26 @@ app.use(
// VIEW CONFIGURATION
//==============================================================================
app.set('views', path.join(__dirname, 'views'));
// configure the default views directory.
const views = path.join(__dirname, 'views');
app.set('views', views);
// reconfigure nunjucks.
cons.requires.nunjucks = nunjucks.configure(views, {
autoescape: true,
trimBlocks: true,
lstripBlocks: true,
watch: process.env.NODE_ENV === 'development',
});
// assign the nunjucks engine to .njk files.
app.engine('njk', cons.nunjucks);
// assign the ejs engine to .ejs and .html files.
app.engine('ejs', cons.ejs);
app.engine('html', cons.ejs);
// set .ejs as the default extension.
app.set('view engine', 'ejs');
//==============================================================================
+4
View File
@@ -18,6 +18,10 @@
"value": "",
"required": true
},
"MAILGUN_SMTP_PASSWORD": {
"value": "",
"required": true
},
"NODE_ENV": "production",
"REWRITE_ENV": "TALK_MONGO_URL:MONGO_URI,TALK_REDIS_URL:REDIS_URL,TALK_SMTP_HOST:MAILGUN_SMTP_SERVER,TALK_SMTP_PORT:MAILGUN_SMTP_PORT,TALK_SMTP_USERNAME:MAILGUN_SMTP_LOGIN,TALK_SMTP_PASSWORD:MAILGUN_SMTP_PASSWORD",
"NPM_CONFIG_PRODUCTION": "false"
+1 -1
View File
@@ -7,7 +7,7 @@
const util = require('./util');
const program = require('commander');
const parseDuration = require('ms');
const Table = require('cli-table');
const Table = require('cli-table2');
const AssetModel = require('../models/asset');
const CommentModel = require('../models/comment');
const AssetsService = require('../services/assets');
+1 -2
View File
@@ -329,8 +329,7 @@ async function createSeedPlugin() {
if (answers.addPluginsJson) {
const pluginsJson = path.resolve(__dirname, '..', 'plugins.json');
fs
.readJson(pluginsJson)
fs.readJson(pluginsJson)
.then(j => {
// This is a client-side plugin, let's push this.
if (answers.client) {
+5 -2
View File
@@ -8,7 +8,7 @@ const util = require('./util');
const program = require('commander');
const mongoose = require('../services/mongoose');
const TokensService = require('../services/tokens');
const Table = require('cli-table');
const Table = require('cli-table2');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
@@ -49,7 +49,10 @@ async function revokeToken(tokenID) {
async function createToken(userID, tokenName) {
try {
let { pat: { id }, jwt } = await TokensService.create(userID, tokenName);
let {
pat: { id },
jwt,
} = await TokensService.create(userID, tokenName);
console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`);
+1 -1
View File
@@ -8,7 +8,7 @@ const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const { stripIndent } = require('common-tags');
const Table = require('cli-table');
const Table = require('cli-table2');
// Make things colorful!
require('colors');
@@ -0,0 +1,14 @@
import {
SHOW_REJECT_USERNAME_DIALOG,
HIDE_REJECT_USERNAME_DIALOG,
} from '../constants/rejectUsernameDialog';
export const showRejectUsernameDialog = ({ userId, username }) => ({
type: SHOW_REJECT_USERNAME_DIALOG,
userId,
username,
});
export const hideRejectUsernameDialog = () => ({
type: HIDE_REJECT_USERNAME_DIALOG,
});
@@ -76,7 +76,7 @@ ActionsMenu.propTypes = {
icon: PropTypes.string,
children: PropTypes.node,
className: PropTypes.string,
label: PropTypes.string,
label: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
buttonClassNames: PropTypes.string,
};
@@ -15,7 +15,7 @@ const ActionsMenuItem = props => (
ActionsMenuItem.propTypes = {
className: PropTypes.string,
children: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
};
export default ActionsMenuItem;
+11 -12
View File
@@ -5,6 +5,7 @@ import styles from './NotFound.css';
import { Button, TextField, Alert, Success } from 'coral-ui';
import Recaptcha from 'react-recaptcha';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
class AdminLogin extends React.Component {
constructor(props) {
@@ -41,13 +42,13 @@ class AdminLogin extends React.Component {
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
id="email"
label="Email Address"
label={t('login.email_address')}
value={this.state.email}
onChange={e => this.setState({ email: e.target.value })}
/>
<TextField
id="password"
label="Password"
label={t('login.password')}
value={this.state.password}
onChange={e => this.setState({ password: e.target.value })}
type="password"
@@ -60,10 +61,10 @@ class AdminLogin extends React.Component {
full
onClick={this.handleSignIn}
>
Sign In
{t('login.sign_in_button')}
</Button>
<p className={styles.forgotPasswordCTA}>
Forgot your password?{' '}
{t('login.forgot_password')}{' '}
<a
href="#"
className={styles.forgotPasswordLink}
@@ -72,7 +73,7 @@ class AdminLogin extends React.Component {
this.setState({ requestPassword: true });
}}
>
Request a new one.
{t('login.request_passowrd')}
</a>
</p>
{loginMaxExceeded && (
@@ -95,14 +96,14 @@ class AdminLogin extends React.Component {
>
{this.props.passwordRequestSuccess}{' '}
<a className={styles.signInLink} href="#">
Sign in
{t('login.sign_in')}
</a>
<Success />
</p>
) : (
<form onSubmit={this.handleRequestPassword}>
<TextField
label="Email Address"
label={t('login.email_address')}
value={this.state.email}
onChange={e => this.setState({ email: e.target.value })}
/>
@@ -112,17 +113,15 @@ class AdminLogin extends React.Component {
full
onClick={this.handleRequestPassword}
>
Reset Password
{t('login.reset_password')}
</Button>
</form>
);
return (
<Layout fixedDrawer restricted={true}>
<div className={cn(styles.loginLayout, 'talk-admin-login')}>
<h1 className={styles.loginHeader}>Team sign in</h1>
<p className={styles.loginCTA}>
Sign in to interact with your community.
</p>
<h1 className={styles.loginHeader}>{t('login.team_sign_in')}</h1>
<p className={styles.loginCTA}>{t('login.sign_in_message')}</p>
{this.state.requestPassword ? requestPasswordForm : signInForm}
</div>
</Layout>
@@ -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,9 @@ class BanUserDialog extends React.Component {
}
handleMessageChange = e => {
const { value: message } = e;
const {
target: { value: message },
} = e;
this.setState({ message });
};
@@ -30,6 +32,12 @@ class BanUserDialog extends React.Component {
});
};
handlePerform = () => {
this.props.onPerform({
message: this.state.message,
});
};
renderStep0() {
const { onCancel, username, info } = this.props;
@@ -63,7 +71,7 @@ class BanUserDialog extends React.Component {
}
renderStep1() {
const { onCancel, onPerform } = this.props;
const { onCancel } = this.props;
const { message } = this.state;
return (
@@ -95,7 +103,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')}
@@ -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
@@ -1,20 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './CountBadge.css';
import t from 'coral-framework/services/i18n';
import { humanizeNumber } from 'coral-framework/helpers/numbers';
const CountBadge = ({ count }) => {
let number = count;
// shorten large counts to abbreviations
if (number / 1e9 > 1) {
number = `${(number / 1e9).toFixed(1)}${t('modqueue.billion')}`;
} else if (number / 1e6 > 1) {
number = `${(number / 1e6).toFixed(1)}${t('modqueue.million')}`;
} else if (number / 1e3 > 1) {
number = `${(number / 1e3).toFixed(1)}${t('modqueue.thousand')}`;
}
number = humanizeNumber(number);
return <span className={styles.count}>{number}</span>;
};
@@ -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;
@@ -43,21 +43,21 @@ class ForgotPassword extends React.Component {
<form onSubmit={this.handleSubmit}>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
label="Email Address"
label={t('login.email_address')}
value={email}
onChange={this.handleEmailChange}
/>
<Button type="submit" cStyle="black" full>
Reset Password
{t('login.reset_password')}
</Button>
<p className={styles.cta}>
Go back to{' '}
{t('login.go_back')}{' '}
<a
href="#"
className={styles.signInLink}
onClick={this.handleSignInLink}
>
Sign In
{t('login.sign_in')}
</a>
.
</p>
+2 -2
View File
@@ -83,7 +83,7 @@ const CoralHeader = ({
target="_blank"
rel="noopener noreferrer"
>
View latest version
{t('configure.view_last_version')}
</a>
</MenuItem>
<MenuItem>
@@ -92,7 +92,7 @@ const CoralHeader = ({
target="_blank"
rel="noopener noreferrer"
>
Report a bug or give feedback
{t('configure.report_bug_or_feedback')}
</a>
</MenuItem>
{currentUser && (
@@ -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,85 @@
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"
rel="noopener noreferrer"
>
{t('user_detail.learn_more')}
</a>
</div>
)}
</div>
</ClickOutside>
);
}
}
export default KarmaTooltip;
+3 -2
View File
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
import styles from './Login.css';
import Layout from 'coral-admin/src/components/Layout';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
class LoginContainer extends Component {
renderForm() {
@@ -19,8 +20,8 @@ class LoginContainer extends Component {
return (
<Layout fixedDrawer restricted={true}>
<div className={cn(styles.layout, 'talk-admin-login')}>
<h1 className={styles.header}>Team sign in</h1>
<p className={styles.cta}>Sign in to interact with your community.</p>
<h1 className={styles.header}>{t('login.team_sign_in')}</h1>
<p className={styles.cta}>{t('login.sign_in_message')}</p>
{this.renderForm()}
</div>
</Layout>
@@ -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}
@@ -0,0 +1,80 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 400px;
top: 50%;
transform: translateY(-50%);
padding: 20px;
border-radius: 4px;
}
.header {
color: black;
font-size: 1.5em;
font-weight: 500;
margin: 0 0 8px 0;
}
.close {
display: block;
position: absolute;
top: 24px;
right: 20px;
}
.closeButton {
font-size: 24px;
line-height: 14px;
color: #363636;
&:hover {
color: #6b6b6b;
}
}
.legend {
padding: 0;
font-weight: bold;
}
div.radioGroup {
margin-top: 6px;
}
label.radioGroup {
&:global(.is-checked) > :global(.mdl-radio__outer-circle),
> :global(.mdl-radio__outer-circle) {
border-color: #212121;
}
> :global(.mdl-radio__inner-circle) {
background: #212121;
}
> :global(.mdl-radio__label) {
font-size: 14px;
line-height: 20px;
}
}
.messageInput {
border-radius: 3px;
width: 100%;
padding: 10px;
font-size: 14px;
box-sizing: border-box;
}
.cancel {
margin-right: 5px;
}
.perform {
min-width: 90px;
}
.buttons {
margin-top: 8px;
margin-bottom: 6px;
text-align: right;
}
@@ -0,0 +1,138 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Dialog, BareButton } from 'coral-ui';
import styles from './RejectUsernameDialog.css';
import cn from 'classnames';
import { RadioGroup, Radio } from 'react-mdl';
import Button from 'coral-ui/components/Button';
import { username as flagReason } from 'coral-framework/graphql/flagReasons';
import t from 'coral-framework/services/i18n';
const initialState = { reason: flagReason.offensive, message: '' };
class RejectUsernameDialog extends React.Component {
state = initialState;
componentWillReceiveProps(next) {
if (this.props.open && !next.open) {
this.setState(initialState);
}
}
handleReasonChange = event => {
this.setState({ reason: event.target.value });
};
handleMessageChange = event => {
this.setState({ message: event.target.value });
};
handlePerform = () => {
this.props.onPerform({
reason: this.state.reason,
message: this.state.message,
});
};
render() {
const { open, onCancel } = this.props;
const { reason, message } = this.state;
return (
<Dialog
className={cn(styles.dialog, 'talk-admin-reject-username-dialog')}
id="rejectUsernameDialog"
onCancel={onCancel}
open={open}
>
<div className={styles.close}>
<BareButton
aria-label="Close"
onClick={onCancel}
className={styles.closeButton}
>
×
</BareButton>
</div>
<section className="talk-admin-reject-username-dialog-section">
<h1 className={styles.header}>
{t('reject_username_dialog.title')}: {this.props.username}
</h1>
<p className={styles.description}>
{t('reject_username_dialog.description')}
</p>
<fieldset>
<legend className={styles.legend}>
{t('reject_username_dialog.reason')}
</legend>
<RadioGroup
name="reason"
value={reason}
childContainer="div"
onChange={this.handleReasonChange}
className={styles.radioGroup}
>
<Radio value={flagReason.offensive}>
{t('flag_reasons.username.offensive')}
</Radio>
<Radio value={flagReason.nolike}>
{t('flag_reasons.username.nolike')}
</Radio>
<Radio value={flagReason.impersonating}>
{t('flag_reasons.username.impersonating')}
</Radio>
<Radio value={flagReason.spam}>
{t('flag_reasons.username.spam')}
</Radio>
<Radio value={flagReason.other}>
{t('flag_reasons.username.other')}
</Radio>
</RadioGroup>
{reason === flagReason.other && (
<fieldset>
<legend className={styles.legend}>
{t('reject_username_dialog.message')}
</legend>
<textarea
rows={5}
className={styles.messageInput}
value={message}
onChange={this.handleMessageChange}
/>
</fieldset>
)}
</fieldset>
<div className={styles.buttons}>
<Button
cStyle="white"
className={styles.cancel}
onClick={onCancel}
raised
>
{t('reject_username_dialog.cancel')}
</Button>
<Button
cStyle="black"
className={cn(
styles.perform,
'talk-admin-reject-username-dialog-continue'
)}
onClick={this.handlePerform}
raised
>
{t('reject_username_dialog.reject_username')}
</Button>
</div>
</section>
</Dialog>
);
}
}
RejectUsernameDialog.propTypes = {
open: PropTypes.bool.isRequired,
onPerform: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
username: PropTypes.string,
};
export default RejectUsernameDialog;
+50 -41
View File
@@ -4,6 +4,8 @@ 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';
import t from 'coral-framework/services/i18n';
class SignIn extends React.Component {
recaptcha = null;
@@ -33,48 +35,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={t('login.email_address')}
value={email}
onChange={this.handleEmailChange}
/>
<TextField
id="password"
label={t('login.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>
{t('login.sign_in')}
</Button>
<p className={styles.forgotPasswordCTA}>
{/* TODO: translate */}
{t('login.forgot_password')}{' '}
<a
href="#"
className={styles.forgotPasswordLink}
onClick={this.handleForgotPasswordLink}
>
{t('login.request_passowrd')}
</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;
}
+159 -39
View File
@@ -6,11 +6,15 @@ import styles from './UserDetail.css';
import UserHistory from './UserHistory';
import { Slot } from 'coral-framework/components';
import UserDetailCommentList from '../components/UserDetailCommentList';
import {
getReliability,
isSuspended,
isUsernameRejected,
isUsernameChanged,
isBanned,
getKarma,
} from 'coral-framework/utils/user';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import ClickOutside from 'coral-framework/components/ClickOutside';
import {
@@ -25,7 +29,27 @@ 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';
import { humanizeNumber } from 'coral-framework/helpers/numbers';
const filterOutLocalProfiles = ({ provider }) => provider !== 'local';
/**
* getUserStatusArray
* returns an array of active status(es)
* i.e if suspension is active, it returns suspension
*/
function getUserStatusArray(user) {
const statusMap = {
suspended: isSuspended,
banned: isBanned,
usernameRejected: isUsernameRejected,
usernameChanged: isUsernameChanged,
};
return Object.keys(statusMap).filter(k => statusMap[k](user));
}
class UserDetail extends React.Component {
changeTab = tab => {
@@ -44,6 +68,12 @@ class UserDetail extends React.Component {
username: this.props.root.user.username,
});
showRejectUsernameDialog = () =>
this.props.showRejectUsernameDialog({
userId: this.props.root.user.id,
username: this.props.root.user.username,
});
renderLoading() {
return (
<ClickOutside onClickOutside={this.props.hideUserDetail}>
@@ -64,22 +94,56 @@ class UserDetail extends React.Component {
);
}
getActionMenuLabel() {
const { root: { user } } = this.props;
getActionMenuLabel(user) {
const userStatusArr = getUserStatusArray(user);
const count = userStatusArr.length;
if (isBanned(user)) {
return 'Banned';
} else if (isSuspended(user)) {
return 'Suspended';
if (count > 1) {
return `Status (${count})`;
} else {
const activeStatus = userStatusArr[0];
switch (activeStatus) {
case 'suspended':
return t('user_detail.suspended');
case 'banned':
return t('user_detail.banned');
case 'usernameRejected':
return (
<span>
{t('user_detail.username')}
{` `}
<Icon name="cancel" />
</span>
);
case 'usernameChanged':
return (
<span>
{t('user_detail.username')}
{` `}
<Icon name="access_time" />
</span>
);
default:
return activeStatus;
}
}
return '';
}
goToReportedUsernames = () => {
const { router } = this.props;
router.push('/admin/community/flagged');
};
renderLoaded() {
const {
root,
root: { me, user, totalComments, rejectedComments },
root: {
me,
user,
totalComments,
rejectedComments,
settings: { karmaThresholds },
},
activeTab,
selectedCommentIds,
toggleSelect,
@@ -97,7 +161,7 @@ class UserDetail extends React.Component {
} = this.props;
// if totalComments is 0, you're dividing by zero
let rejectedPercent = rejectedComments / totalComments * 100;
let rejectedPercent = (rejectedComments / totalComments) * 100;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
rejectedPercent = 0;
@@ -105,6 +169,8 @@ class UserDetail extends React.Component {
const banned = isBanned(user);
const suspended = isSuspended(user);
const usernameRejected = isUsernameRejected(user);
const usernameChanged = isUsernameChanged(user);
const slotPassthrough = {
root,
@@ -137,7 +203,7 @@ class UserDetail extends React.Component {
},
'talk-admin-user-detail-actions-button'
)}
label={this.getActionMenuLabel()}
label={this.getActionMenuLabel(user)}
>
{suspended ? (
<ActionsMenuItem onClick={() => unsuspendUser({ id: user.id })}>
@@ -164,6 +230,27 @@ class UserDetail extends React.Component {
{t('user_detail.ban')}
</ActionsMenuItem>
)}
{usernameChanged && (
<ActionsMenuItem onClick={this.goToReportedUsernames}>
{t('user_detail.username_needs_approval')}
{` `}
<Icon name="launch" />
</ActionsMenuItem>
)}
{usernameRejected && !usernameChanged ? (
<ActionsMenuItem disabled>
{t('user_detail.username_rejected')}
</ActionsMenuItem>
) : (
<ActionsMenuItem
onClick={this.showRejectUsernameDialog}
disabled={me.id === user.id || usernameChanged}
>
{t('user_detail.reject_username')}
</ActionsMenuItem>
)}
</ActionsMenu>
)}
@@ -177,7 +264,19 @@ class UserDetail extends React.Component {
<div>
<ul className={styles.userDetailList}>
<li>
<li className={styles.userDetailItem}>
<Icon name="perm_identity" />
<span className={styles.userDetailItem}>
{t('user_detail.id')}:
</span>
{user.id}{' '}
<ButtonCopyToClipboard
className={styles.copyButton}
icon="content_copy"
copyText={user.id}
/>
</li>
<li className={styles.userDetailItem}>
<Icon name="assignment_ind" />
<span className={styles.userDetailItem}>
{t('user_detail.member_since')}:
@@ -185,20 +284,35 @@ class UserDetail extends React.Component {
{new Date(user.created_at).toLocaleString()}
</li>
{user.profiles.map(({ id }) => (
<li key={id}>
<Icon name="email" />
<span className={styles.userDetailItem}>
{t('user_detail.email')}:
</span>
{id}{' '}
<ButtonCopyToClipboard
className={styles.copyButton}
icon="content_copy"
copyText={id}
/>
</li>
))}
<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
.filter(filterOutLocalProfiles)
.map(({ provider, id }) => (
<li key={id} className={styles.userDetailItem}>
<Icon name="device_hub" />
<span className={styles.userDetailItem}>
{capitalize(provider)} {t('user_detail.id')}:
</span>
{id}{' '}
<ButtonCopyToClipboard
className={styles.copyButton}
icon="content_copy"
copyText={id}
/>
</li>
))}
</ul>
<ul className={styles.stats}>
@@ -216,18 +330,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)]
)}
>
{humanizeNumber(user.reliable.commenterKarma)}
</span>
</div>
<KarmaTooltip thresholds={karmaThresholds.comment} />
</li>
</ul>
</div>
@@ -339,6 +456,7 @@ class UserDetail extends React.Component {
}
UserDetail.propTypes = {
router: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
@@ -355,11 +473,13 @@ UserDetail.propTypes = {
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
showRejectUsernameDialog: PropTypes.func,
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
modal: PropTypes.bool,
rejectUsername: PropTypes.func.isRequired,
};
export default UserDetail;
@@ -114,6 +114,7 @@ class UserDetailComment extends React.Component {
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
@@ -10,7 +10,10 @@ import ApproveButton from './ApproveButton';
const UserDetailCommentList = props => {
const {
root,
root: { user, comments: { nodes, hasNextPage } },
root: {
user,
comments: { nodes, hasNextPage },
},
acceptComment,
rejectComment,
selectedCommentIds,
@@ -0,0 +1,2 @@
export const SHOW_REJECT_USERNAME_DIALOG = 'SHOW_REJECT_USERNAME_DIALOG';
export const HIDE_REJECT_USERNAME_DIALOG = 'HIDE_REJECT_USERNAME_DIALOG';
@@ -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' });
@@ -77,7 +77,10 @@ const mapDispatchToProps = dispatch => ({
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withBanUser,
withSetCommentStatus
)(BanUserDialogContainer);
+6 -1
View File
@@ -6,6 +6,7 @@ import Login from '../containers/Login';
import { FullLoading } from '../components/FullLoading';
import BanUserDialog from './BanUserDialog';
import SuspendUserDialog from './SuspendUserDialog';
import RejectUsernameDialog from './RejectUsernameDialog';
import { toggleModal as toggleShortcutModal } from '../actions/moderation';
import { logout } from 'coral-framework/actions/auth';
import { can } from 'coral-framework/services/perms';
@@ -41,6 +42,7 @@ class LayoutContainer extends React.Component {
>
<BanUserDialog />
<SuspendUserDialog />
<RejectUsernameDialog />
<UserDetail />
{children}
</Layout>
@@ -79,4 +81,7 @@ const mapDispatchToProps = dispatch =>
dispatch
);
export default connect(mapStateToProps, mapDispatchToProps)(LayoutContainer);
export default connect(
mapStateToProps,
mapDispatchToProps
)(LayoutContainer);
@@ -0,0 +1,92 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import RejectUsernameDialog from '../components/RejectUsernameDialog';
import { hideRejectUsernameDialog } from '../actions/rejectUsernameDialog';
import {
withRejectUsername,
withPostFlag,
} from 'coral-framework/graphql/mutations';
import { notify } from 'coral-framework/actions/notification';
import { compose } from 'react-apollo';
import { getErrorMessages } from 'coral-framework/utils';
class RejectUsernameDialogContainer extends Component {
rejectUsername = async ({ reason, message }) => {
const {
postFlag,
rejectUsername,
hideRejectUsernameDialog,
userId,
} = this.props;
// First flag the user.
try {
await postFlag({
item_id: userId,
item_type: 'USERS',
reason,
message,
});
} catch (error) {
// Ignore already exists error, otherwise show error.
if (
error.errors &&
(error.errors.length !== 1 ||
error.errors[0].translation_key !== 'ALREADY_EXISTS')
) {
notify('error', getErrorMessages(error));
}
}
await rejectUsername(userId);
hideRejectUsernameDialog();
};
render() {
return (
<RejectUsernameDialog
open={this.props.open}
onPerform={this.rejectUsername}
onCancel={this.props.hideRejectUsernameDialog}
username={this.props.username}
/>
);
}
}
RejectUsernameDialogContainer.propTypes = {
rejectUsername: PropTypes.func.isRequired,
hideRejectUsernameDialog: PropTypes.func,
open: PropTypes.bool,
userId: PropTypes.string,
username: PropTypes.string,
};
const mapStateToProps = ({
rejectUsernameDialog: { open, userId, username },
}) => ({
open,
userId,
username,
});
const mapDispatchToProps = dispatch => ({
...bindActionCreators(
{
hideRejectUsernameDialog,
notify,
},
dispatch
),
});
export default compose(
connect(
mapStateToProps,
mapDispatchToProps
),
withRejectUsername,
withPostFlag({ notifyOnError: false })
)(RejectUsernameDialogContainer);
+5 -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,7 @@ SignInContainer.propTypes = {
requireRecaptcha: PropTypes.bool.isRequired,
};
export default compose(withSignIn)(SignInContainer);
export default compose(
withSignIn,
withPopupAuthHandler
)(SignInContainer);
@@ -86,7 +86,10 @@ const mapDispatchToProps = dispatch => ({
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withSuspendUser,
withSetCommentStatus,
withOrganizationName
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import UserDetail from '../components/UserDetail';
import withQuery from 'coral-framework/hocs/withQuery';
import { withRouter } from 'react-router';
import {
getDefinitionName,
getSlotFragmentSpreads,
@@ -21,11 +22,14 @@ import {
withSetCommentStatus,
withUnbanUser,
withUnsuspendUser,
withRejectUsername,
withPostFlag,
} from 'coral-framework/graphql/mutations';
import UserDetailComment from './UserDetailComment';
import update from 'immutability-helper';
import { showBanUserDialog } from 'actions/banUserDialog';
import { showSuspendUserDialog } from 'actions/suspendUserDialog';
import { showRejectUsernameDialog } from 'actions/rejectUsernameDialog';
const commentConnectionFragment = gql`
fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection {
@@ -131,6 +135,7 @@ class UserDetailContainer extends React.Component {
loading={loading}
error={this.props.data && this.props.data.error}
loadMore={this.loadMore}
rejectUsername={this.props.rejectUsername}
{...this.props}
/>
);
@@ -148,6 +153,7 @@ UserDetailContainer.propTypes = {
selectedCommentIds: PropTypes.array,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
rejectUsername: PropTypes.func.isRequired,
userId: PropTypes.string,
};
@@ -179,12 +185,14 @@ export const withUserDetailQuery = withQuery(
id
username
created_at
email
profiles {
id
provider
}
reliable {
flagger
commenter
commenterKarma
}
state {
status {
@@ -225,6 +233,14 @@ export const withUserDetailQuery = withQuery(
}
${getSlotFragmentSpreads(slots, 'user')}
}
settings {
karmaThresholds {
comment {
reliable
unreliable
}
}
}
me {
id
}
@@ -265,6 +281,7 @@ const mapDispatchToProps = dispatch => ({
{
showBanUserDialog,
showSuspendUserDialog,
showRejectUsernameDialog,
changeTab,
clearUserDetailSelections,
toggleSelectCommentInUserDetail,
@@ -277,9 +294,15 @@ const mapDispatchToProps = dispatch => ({
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withUserDetailQuery,
withSetCommentStatus,
withUnbanUser,
withUnsuspendUser
withUnsuspendUser,
withRejectUsername,
withPostFlag,
withRouter
)(UserDetailContainer);
+83 -64
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 } }) => ({
@@ -47,7 +66,11 @@ export default {
});
},
}),
SuspendUser: ({ variables: { input: { id, until } } }) => ({
SuspendUser: ({
variables: {
input: { id, until },
},
}) => ({
update: proxy => {
const fragmentId = `User_${id}`;
@@ -73,7 +96,11 @@ export default {
});
},
}),
UnsuspendUser: ({ variables: { input: { id } } }) => ({
UnsuspendUser: ({
variables: {
input: { id },
},
}) => ({
update: proxy => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({
@@ -98,7 +125,11 @@ export default {
});
},
}),
BanUser: ({ variables: { input: { id } } }) => ({
BanUser: ({
variables: {
input: { id },
},
}) => ({
update: proxy => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({
@@ -123,7 +154,11 @@ export default {
});
},
}),
UnbanUser: ({ variables: { input: { id } } }) => ({
UnbanUser: ({
variables: {
input: { id },
},
}) => ({
update: proxy => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({
@@ -156,7 +191,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;
@@ -173,6 +210,12 @@ export default {
},
updateQueries: {
TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => {
// No need to update, when user was not in the flagged users queue.
// TODO: this should be more generic, e.g. looking at the history.
if (!prev.flaggedUsers.nodes.find(node => node.id === id)) {
return prev;
}
const decrement = {
flaggedUsernamesCount: { $apply: count => count - 1 },
};
@@ -185,41 +228,14 @@ 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;
},
},
update: proxy => {
proxy.writeFragment({
fragment: gql`
fragment Talk_ApproveUsername on User {
state {
status {
username {
status
}
}
}
}
`,
id: `User_${id}`,
data: {
__typename: 'User',
state: {
__typename: 'UserState',
status: {
__typename: 'UserStatus',
username: {
__typename: 'UsernameStatus',
status: 'APPROVED',
},
},
},
},
});
},
}),
RejectUsername: ({ variables: { id } }) => ({
optimisticResponse: {
@@ -231,6 +247,12 @@ export default {
},
updateQueries: {
TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => {
// No need to update, when user was not in the flagged users queue.
// TODO: this should be more generic, e.g. looking at the history.
if (!prev.flaggedUsers.nodes.find(node => node.id === id)) {
return prev;
}
const decrement = {
flaggedUsernamesCount: { $apply: count => count - 1 },
};
@@ -251,35 +273,6 @@ export default {
return updated;
},
},
update: proxy => {
proxy.writeFragment({
fragment: gql`
fragment Talk_RejectUsername on User {
state {
status {
username {
status
}
}
}
}
`,
id: `User_${id}`,
data: {
__typename: 'User',
state: {
__typename: 'UserState',
status: {
__typename: 'UserStatus',
username: {
__typename: 'UsernameStatus',
status: 'REJECTED',
},
},
},
},
});
},
}),
UpdateSettings: ({ variables: { input } }) => ({
updateQueries: {
@@ -295,12 +288,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),
},
+2
View File
@@ -5,10 +5,12 @@ import moderation from './moderation';
import install from './install';
import banUserDialog from './banUserDialog';
import suspendUserDialog from './suspendUserDialog';
import rejectUsernameDialog from './rejectUsernameDialog';
import userDetail from './userDetail';
import ui from './ui';
export default {
rejectUsernameDialog,
banUserDialog,
configure,
suspendUserDialog,
@@ -0,0 +1,29 @@
import {
SHOW_REJECT_USERNAME_DIALOG,
HIDE_REJECT_USERNAME_DIALOG,
} from '../constants/rejectUsernameDialog';
const initialState = {
open: false,
userId: null,
username: '',
};
export default function rejectUsernameDialog(state = initialState, action) {
switch (action.type) {
case SHOW_REJECT_USERNAME_DIALOG:
return {
...state,
open: true,
userId: action.userId,
username: action.username,
};
case HIDE_REJECT_USERNAME_DIALOG:
return {
...state,
open: false,
};
default:
return state;
}
}
@@ -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
)}
@@ -71,7 +71,7 @@ class RejectUsernameDialog extends Component {
<Dialog
className={cn(
styles.suspendDialog,
'talk-admin-reject-username-dialog'
'talk-admin-reject-reported-username-dialog'
)}
id="rejectUsernameDialog"
open={open}
@@ -85,7 +85,7 @@ class RejectUsernameDialog extends Component {
<div
className={cn(
styles.container,
`talk-admin-reject-username-dialog-step-${stage}`
`talk-admin-reject-reported-username-dialog-step-${stage}`
)}
>
<div className={styles.description}>
@@ -101,7 +101,7 @@ class RejectUsernameDialog extends Component {
<div className={styles.emailContainer}>
<textarea
rows={5}
className={cn(styles.emailInput, 'talk-admin-reject-username-dialog-suspension-message')}
className={cn(styles.emailInput, 'talk-admin-reject-reported-username-dialog-suspension-message')}
value={this.state.email}
onChange={this.onEmailChange}/>
</div>
@@ -110,7 +110,7 @@ class RejectUsernameDialog extends Component {
<div
className={cn(
styles.modalButtons,
'talk-admin-reject-username-dialog-buttons'
'talk-admin-reject-reported-username-dialog-buttons'
)}
>
{Object.keys(stages[stage].options).map((key, i) => (
@@ -118,7 +118,7 @@ class RejectUsernameDialog extends Component {
key={i}
className={cn(
'talk-admin-username-dialog-button',
`talk-admin-reject-username-dialog-button-${key}`
`talk-admin-reject-reported-username-dialog-button-${key}`
)}
onClick={this.onActionClick(stage, i)}
>
@@ -46,7 +46,11 @@ class FlaggedAccountsContainer extends Component {
document: USERNAME_FLAGGED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameFlagged: user } } }
{
subscriptionData: {
data: { usernameFlagged: user },
},
}
) => {
return handleFlaggedAccountsChange(prev, user, () => {
const msg = t(
@@ -62,7 +66,11 @@ class FlaggedAccountsContainer extends Component {
document: USERNAME_APPROVED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameApproved: user } } }
{
subscriptionData: {
data: { usernameApproved: user },
},
}
) => {
return handleFlaggedAccountsChange(prev, user, () => {
const msg = t(
@@ -78,7 +86,11 @@ class FlaggedAccountsContainer extends Component {
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameRejected: user } } }
{
subscriptionData: {
data: { usernameRejected: user },
},
}
) => {
return handleFlaggedAccountsChange(prev, user, () => {
const msg = t(
@@ -96,7 +108,9 @@ class FlaggedAccountsContainer extends Component {
prev,
{
subscriptionData: {
data: { usernameChanged: { previousUsername, user } },
data: {
usernameChanged: { previousUsername, user },
},
},
}
) => {
@@ -297,7 +311,10 @@ const mapDispatchToProps = dispatch =>
);
export default compose(
connect(null, mapDispatchToProps),
connect(
null,
mapDispatchToProps
),
withApproveUsername,
withQuery(
gql`
@@ -15,7 +15,11 @@ class IndicatorContainer extends Component {
document: USERNAME_FLAGGED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameFlagged: user } } }
{
subscriptionData: {
data: { usernameFlagged: user },
},
}
) => {
return handleIndicatorChange(prev, user);
},
@@ -24,7 +28,11 @@ class IndicatorContainer extends Component {
document: USERNAME_APPROVED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameApproved: user } } }
{
subscriptionData: {
data: { usernameApproved: user },
},
}
) => {
return handleIndicatorChange(prev, user);
},
@@ -33,7 +41,11 @@ class IndicatorContainer extends Component {
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameRejected: user } } }
{
subscriptionData: {
data: { usernameRejected: user },
},
}
) => {
return handleIndicatorChange(prev, user);
},
@@ -42,7 +54,13 @@ class IndicatorContainer extends Component {
document: USERNAME_CHANGED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameChanged: { user } } } }
{
subscriptionData: {
data: {
usernameChanged: { user },
},
},
}
) => {
return handleIndicatorChange(prev, user);
},
@@ -98,9 +116,15 @@ const fields = `
status {
username {
status
history {
status
}
}
}
}
action_summaries {
count
}
`;
const USERNAME_FLAGGED_SUBSCRIPTION = gql`
@@ -200,7 +200,10 @@ const SEARCH_QUERY = gql`
`;
export default compose(
connect(null, mapDispatchToProps),
connect(
null,
mapDispatchToProps
),
withSetUserRole,
withUnsuspendUser,
withUnbanUser,
@@ -19,6 +19,9 @@ const mapDispatchToProps = dispatch =>
);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withRejectUsername
)(RejectUsernameDialog);
@@ -106,6 +106,23 @@ export function handleFlaggedAccountsChange(root, user, notify) {
}
}
export const wasUsernameReported = user => {
const previousStatus =
user.state.status.username.history[
user.state.status.username.history.length - 2
];
// Check for correct previous status
if (!['SET', 'CHANGES'].includes(previousStatus)) {
return false;
}
// Check for flags
if (user.action_summaries.every(as => as.count === 0)) {
return false;
}
return true;
};
/**
* Track indicator status
* @param {Object} root current state of the store
@@ -119,7 +136,9 @@ export function handleIndicatorChange(root, user) {
return incrementFlaggedUserCount(root);
case 'APPROVED':
case 'REJECTED':
return decrementFlaggedUserCount(root);
if (wasUsernameReported(user)) {
return decrementFlaggedUserCount(root);
}
default:
}
}
@@ -34,7 +34,7 @@ class EmbedLink extends Component {
`;
return (
<ConfigureCard title={'Embed Comment Stream'}>
<ConfigureCard title={t('configure.embed_comment_stream')}>
<p>{t('configure.copy_and_paste')}</p>
<textarea
rows={5}
@@ -3,7 +3,7 @@ import { SelectField, Option } from 'react-mdl-selectfield';
import t from 'coral-framework/services/i18n';
import styles from './StreamSettings.css';
import { Textfield } from 'react-mdl';
import { Icon, TextArea } from 'coral-ui';
import { Icon } from 'coral-ui';
import PropTypes from 'prop-types';
import Slot from 'coral-framework/components/Slot';
import MarkdownEditor from 'coral-framework/components/MarkdownEditor';
@@ -66,8 +66,8 @@ class StreamSettings extends React.Component {
this.props.updatePending({ updater });
};
updateClosedMessage = event => {
const updater = { closedMessage: { $set: event.target.value } };
updateClosedMessage = value => {
const updater = { closedMessage: { $set: value } };
this.props.updatePending({ updater });
};
@@ -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 },
@@ -164,7 +178,7 @@ class StreamSettings extends React.Component {
>
<p>{t('configure.closed_comments_desc')}</p>
<div>
<TextArea
<MarkdownEditor
className={styles.descriptionBox}
onChange={this.updateClosedMessage}
value={settings.closedMessage}
@@ -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}
@@ -174,7 +174,10 @@ const mapDispatchToProps = dispatch =>
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withUpdateSettings,
withConfigureQuery,
withMergedSettings('root.settings', 'pending', 'mergedSettings')
@@ -42,7 +42,10 @@ export default compose(
}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
@@ -37,7 +37,10 @@ export default compose(
}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
@@ -39,11 +39,16 @@ export default compose(
autoCloseStream
closedTimeout
closedMessage
disableCommenting
disableCommentingMessage
${getSlotFragmentSpreads(slots, 'settings')}
}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
@@ -39,7 +39,10 @@ export default compose(
}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
@@ -83,4 +83,7 @@ const mapDispatchToProps = dispatch =>
dispatch
);
export default connect(mapStateToProps, mapDispatchToProps)(InstallContainer);
export default connect(
mapStateToProps,
mapDispatchToProps
)(InstallContainer);
@@ -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 {
@@ -95,7 +91,6 @@
font-weight: 500;
line-height: 1.2;
max-width: 500px;
a {
display: inline-block;
color: #063b9a;
@@ -103,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;
}
}
}
}
@@ -138,12 +131,10 @@
cursor: pointer;
font-weight: normal;
white-space: nowrap;
&:hover {
text-decoration: underline;
opacity: .9;
}
i {
font-size: 12px;
position: relative;
@@ -172,7 +163,6 @@
align-items: center;
justify-content: flex-end;
margin-right: 14px;
i {
margin-right: 5px;
}
@@ -181,7 +171,6 @@
@media (--big-viewport) {
.root {
margin-bottom: 30px;
&:last-child {
border-bottom: 1px solid #e0e0e0;
}
@@ -190,7 +179,6 @@
.commentContent {
display: flex;
blockquote {
font-size: inherit;
line-height: inherit;
@@ -170,6 +170,7 @@ class Comment extends React.Component {
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
@@ -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, {
@@ -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);
@@ -380,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}>
@@ -401,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}
@@ -423,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}
@@ -3,9 +3,13 @@ import PropTypes from 'prop-types';
import styles from './StorySearch.css';
import { Button, Spinner, Icon } from 'coral-ui';
import Story from './Story';
import t from 'coral-framework/services/i18n';
const StorySearch = props => {
const { root: { assets }, data: { loading } } = props;
const {
root: { assets },
data: { loading },
} = props;
if (!props.moderation.storySearchVisible) {
return null;
@@ -33,14 +37,14 @@ const StorySearch = props => {
onClick={props.search}
raised
>
Search
{t('streams.search')}
</Button>
</div>
<div className={styles.results}>
{props.assetId && (
<div className={styles.cta}>
<a onClick={props.goToModerateAll}>
Moderate comments on All Stories
{t('moderate_all_streams')}
</a>
</div>
)}
@@ -48,13 +52,15 @@ const StorySearch = props => {
{props.moderation.storySearchString ? (
<div className={styles.searchResults}>
<Icon name="search" />
<span className={styles.headlineRecent}>Search Results</span>
<span className={styles.headlineRecent}>
{t('streams.search_results')}
</span>
</div>
) : (
<div className={styles.searchResults}>
<Icon name="access_time" />
<span className={styles.headlineRecent}>
Most Recent Stories
{t('streams.most_recent_stories')}
</span>
</div>
)}
@@ -82,7 +88,9 @@ const StorySearch = props => {
)}
{assets.nodes.length === 0 && (
<div className={styles.noResults}>No results</div>
<div className={styles.noResults}>
{t('streams.search_results')}
</div>
)}
</div>
</div>
@@ -22,7 +22,11 @@ class IndicatorContainer extends Component {
document: COMMENT_ADDED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentAdded: comment } } }
{
subscriptionData: {
data: { commentAdded: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -31,7 +35,11 @@ class IndicatorContainer extends Component {
document: COMMENT_FLAGGED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentFlagged: comment } } }
{
subscriptionData: {
data: { commentFlagged: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -40,7 +48,11 @@ class IndicatorContainer extends Component {
document: COMMENT_EDITED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentEdited: comment } } }
{
subscriptionData: {
data: { commentEdited: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -49,7 +61,11 @@ class IndicatorContainer extends Component {
document: COMMENT_ACCEPTED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentAccepted: comment } } }
{
subscriptionData: {
data: { commentAccepted: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -58,7 +74,11 @@ class IndicatorContainer extends Component {
document: COMMENT_REJECTED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentRejected: comment } } }
{
subscriptionData: {
data: { commentRejected: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -67,7 +87,11 @@ class IndicatorContainer extends Component {
document: COMMENT_RESET_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentReset: comment } } }
{
subscriptionData: {
data: { commentReset: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -71,7 +71,9 @@ class ModerationContainer extends Component {
};
get activeTab() {
const { root: { asset, settings } } = this.props;
const {
root: { asset, settings },
} = this.props;
const id = getAssetId(this.props);
const tab = getTab(this.props);
@@ -94,7 +96,11 @@ class ModerationContainer extends Component {
variables,
updateQuery: (
prev,
{ subscriptionData: { data: { commentAdded: comment } } }
{
subscriptionData: {
data: { commentAdded: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -104,7 +110,11 @@ class ModerationContainer extends Component {
variables,
updateQuery: (
prev,
{ subscriptionData: { data: { commentAccepted: comment } } }
{
subscriptionData: {
data: { commentAccepted: comment },
},
}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
@@ -125,7 +135,11 @@ class ModerationContainer extends Component {
variables,
updateQuery: (
prev,
{ subscriptionData: { data: { commentRejected: comment } } }
{
subscriptionData: {
data: { commentRejected: comment },
},
}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
@@ -146,7 +160,11 @@ class ModerationContainer extends Component {
variables,
updateQuery: (
prev,
{ subscriptionData: { data: { commentReset: comment } } }
{
subscriptionData: {
data: { commentReset: comment },
},
}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
@@ -167,7 +185,11 @@ class ModerationContainer extends Component {
variables,
updateQuery: (
prev,
{ subscriptionData: { data: { commentEdited: comment } } }
{
subscriptionData: {
data: { commentEdited: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -177,7 +199,11 @@ class ModerationContainer extends Component {
variables,
updateQuery: (
prev,
{ subscriptionData: { data: { commentFlagged: comment } } }
{
subscriptionData: {
data: { commentFlagged: comment },
},
}
) => {
return this.handleCommentChange(prev, comment);
},
@@ -289,7 +315,11 @@ class ModerationContainer extends Component {
};
render() {
const { root, root: { asset, settings }, data } = this.props;
const {
root,
root: { asset, settings },
data,
} = this.props;
const assetId = getAssetId(this.props);
if (assetId) {
@@ -546,7 +576,10 @@ const mapDispatchToProps = dispatch => ({
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withSetCommentStatus,
withModQueueQuery
)(ModerationContainer);
@@ -112,4 +112,7 @@ export const withAssetSearchQuery = withQuery(
}
);
export default compose(withRouter, withAssetSearchQuery)(StorySearchContainer);
export default compose(
withRouter,
withAssetSearchQuery
)(StorySearchContainer);
@@ -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>
);
};
@@ -113,4 +113,7 @@ StoriesContainer.propTypes = {
updateAssetState: PropTypes.func.isRequired,
};
export default connect(mapStateToProps, mapDispatchToProps)(StoriesContainer);
export default connect(
mapStateToProps,
mapDispatchToProps
)(StoriesContainer);
+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);
});
@@ -41,7 +41,13 @@ class EmbedContainer extends React.Component {
document: USER_BANNED_SUBSCRIPTION,
updateQuery: (
_,
{ subscriptionData: { data: { userBanned: { state } } } }
{
subscriptionData: {
data: {
userBanned: { state },
},
},
}
) => {
notify('info', t('your_account_has_been_banned'));
props.updateStatus(state.status);
@@ -51,7 +57,13 @@ class EmbedContainer extends React.Component {
document: USER_SUSPENDED_SUBSCRIPTION,
updateQuery: (
_,
{ subscriptionData: { data: { userSuspended: { state } } } }
{
subscriptionData: {
data: {
userSuspended: { state },
},
},
}
) => {
notify('info', t('your_account_has_been_suspended'));
props.updateStatus(state.status);
@@ -61,7 +73,13 @@ class EmbedContainer extends React.Component {
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: (
_,
{ subscriptionData: { data: { usernameRejected: { state } } } }
{
subscriptionData: {
data: {
usernameRejected: { state },
},
},
}
) => {
notify('info', t('your_username_has_been_rejected'));
props.updateStatus(state.status);
@@ -324,7 +342,10 @@ const mapDispatchToProps = dispatch =>
export default compose(
withPopupAuthHandler,
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
branch(props => !props.checkedInitialLogin, renderComponent(Spinner)),
withEmbedQuery
)(EmbedContainer);
+24 -4
View File
@@ -126,7 +126,9 @@ export default {
},
mutations: {
PostComment: ({
variables: { input: { asset_id, body, parent_id, tags = [] } },
variables: {
input: { asset_id, body, parent_id, tags = [] },
},
state: { auth },
}) => ({
optimisticResponse: {
@@ -193,7 +195,13 @@ export default {
updateQueries: {
CoralEmbedStream_Embed: (
prev,
{ mutationResult: { data: { createComment: { comment } } } }
{
mutationResult: {
data: {
createComment: { comment },
},
},
}
) => {
if (
(![ADMIN, MODERATOR].includes(prev.me.role) &&
@@ -208,7 +216,13 @@ export default {
},
CoralEmbedStream_Profile: (
prev,
{ mutationResult: { data: { createComment: { comment } } } }
{
mutationResult: {
data: {
createComment: { comment },
},
},
}
) => {
return update(prev, {
me: {
@@ -224,7 +238,13 @@ export default {
updateQueries: {
CoralEmbedStream_Embed: (
prev,
{ mutationResult: { data: { editComment: { comment } } } }
{
mutationResult: {
data: {
editComment: { comment },
},
},
}
) => {
if (
!['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(comment.status)
+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,
@@ -139,7 +139,10 @@ const mapDispatchToProps = dispatch =>
);
const enhance = compose(
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withSettingsFragments,
withUpdateAssetSettings,
withMergedSettings('asset.settings', 'pending', 'mergedSettings')
@@ -22,7 +22,14 @@ class CommentHistoryContainer extends Component {
limit: 5,
cursor: this.props.root.me.comments.endCursor,
},
updateQuery: (previous, { fetchMoreResult: { me: { comments } } }) => {
updateQuery: (
previous,
{
fetchMoreResult: {
me: { comments },
},
}
) => {
const updated = update(previous, {
me: {
comments: {
@@ -97,7 +104,10 @@ const mapStateToProps = state => ({
});
export default compose(
connect(mapStateToProps, null),
connect(
mapStateToProps,
null
),
withCommentHistoryFragments,
withFetchMore
)(CommentHistoryContainer);
@@ -80,6 +80,7 @@ const mapStateToProps = state => ({
currentUser: state.auth.user,
});
export default compose(connect(mapStateToProps), withProfileQuery)(
ProfileContainer
);
export default compose(
connect(mapStateToProps),
withProfileQuery
)(ProfileContainer);
@@ -55,7 +55,10 @@ export default compose(
${Settings.fragments.root}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withSlotElements({
slot: 'profileSettings',
propName: 'profileSettingsSlotElements',
@@ -117,8 +117,12 @@ export default class Comment extends React.Component {
}
componentWillReceiveProps(next) {
const { comment: { replies: prevReplies } } = this.props;
const { comment: { replies: nextReplies } } = next;
const {
comment: { replies: prevReplies },
} = this.props;
const {
comment: { replies: nextReplies },
} = next;
if (
prevReplies &&
nextReplies &&
@@ -243,7 +247,10 @@ export default class Comment extends React.Component {
}
loadNewReplies = () => {
const { comment: { replies, replyCount, id }, emit } = this.props;
const {
comment: { replies, replyCount, id },
emit,
} = this.props;
if (replyCount > replies.nodes.length) {
this.setState({ loadingState: 'loading' });
this.props
@@ -292,7 +299,11 @@ export default class Comment extends React.Component {
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
const { comment: { replies }, currentUser, liveUpdates } = this.props;
const {
comment: { replies },
currentUser,
liveUpdates,
} = this.props;
const idCursor = this.state.idCursors[0];
const userId = currentUser ? currentUser.id : null;
@@ -745,10 +756,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,
};
@@ -18,6 +18,7 @@ const ModerationLink = props =>
className="talk-embed-stream-moderation-link"
href={`${BASE_PATH}admin/moderate/${props.assetId}`}
target="_blank"
rel="noopener noreferrer"
>
{t('moderate_this_stream')}
</a>
@@ -8,6 +8,7 @@ import Slot from 'coral-framework/components/Slot';
import InfoBox from './InfoBox';
import { can } from 'coral-framework/services/perms';
import ModerationLink from './ModerationLink';
import Markdown from 'coral-framework/components/Markdown';
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
import t, { timeago } from 'coral-framework/services/i18n';
import CommentBox from '../containers/CommentBox';
@@ -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}
@@ -208,14 +211,17 @@ class Stream extends React.Component {
root,
appendItemArray,
asset,
asset: { comment: highlightedComment, settings: { questionBoxEnable } },
asset: {
comment: highlightedComment,
settings: { questionBoxEnable },
},
postComment,
notify,
updateItem,
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 +299,13 @@ class Stream extends React.Component {
)}
</div>
) : (
<p>{asset.settings.closedMessage}</p>
<div>
{asset.isClosed ? (
<Markdown content={asset.settings.closedMessage} />
) : (
<Markdown content={asset.settings.disableCommentingMessage} />
)}
</div>
)}
<Slot fill="stream" passthrough={slotPassthrough} />
@@ -24,6 +24,7 @@ const slots = [
'commentAuthorName',
'commentAuthorTags',
'commentTimestamp',
'commentTombstone',
'commentContent',
];
@@ -215,7 +215,10 @@ const mapStateToProps = state => ({
const enhance = compose(
withHooks(['preSubmit', 'postSubmit']),
connect(mapStateToProps, null)
connect(
mapStateToProps,
null
)
);
export default enhance(CommentBox);
@@ -33,4 +33,7 @@ const mapDispatchToProps = dispatch =>
dispatch
);
export default connect(null, mapDispatchToProps)(CommentNotFound);
export default connect(
null,
mapDispatchToProps
)(CommentNotFound);
@@ -54,7 +54,11 @@ class StreamContainer extends React.Component {
},
updateQuery: (
prev,
{ subscriptionData: { data: { commentEdited } } }
{
subscriptionData: {
data: { commentEdited },
},
}
) => {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
@@ -87,7 +91,14 @@ class StreamContainer extends React.Component {
variables: {
assetId: this.props.asset.id,
},
updateQuery: (prev, { subscriptionData: { data: { commentAdded } } }) => {
updateQuery: (
prev,
{
subscriptionData: {
data: { commentAdded },
},
}
) => {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (
@@ -434,6 +445,8 @@ const fragments = {
questionBoxIcon
closedTimeout
closedMessage
disableCommenting
disableCommentingMessage
charCountEnable
charCount
requireEmailConfirmation
@@ -486,7 +499,10 @@ const mapDispatchToProps = dispatch =>
export default compose(
withFragments(fragments),
withEmit,
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withPostComment,
// `talk-plugin-flags` has a custom error handling logic.
withPostFlag({ notifyOnError: false }),
+7 -1
View File
@@ -96,7 +96,13 @@ export const logout = () => async (
_,
{ rest, client, pym, localStorage }
) => {
await rest('/auth', { method: 'DELETE' });
try {
await rest('/auth', { method: 'DELETE' });
} catch (err) {
// We ignore any REST related errors from the delete action, which may/may
// not have had a cookie/token attached to it. The logout action was still
// called, so we still want to cleanup.
}
// Clear the auth data persisted to localStorage.
cleanAuthData(localStorage);
@@ -78,7 +78,12 @@ function markLinks(body, keyPrefix) {
matches.forEach((match, i) => {
content.push(body.substring(index, match.index));
content.push(
<a key={`${keyPrefix}_${i}`} href={match.url} target="_blank">
<a
key={`${keyPrefix}_${i}`}
href={match.url}
target="_blank"
rel="noopener noreferrer"
>
{match.text}
</a>
);
+4 -1
View File
@@ -103,5 +103,8 @@ export default compose(
size: props => props.size,
defaultComponent: props => props.defaultComponent,
}),
connect(mapStateToProps, null)
connect(
mapStateToProps,
null
)
)(Slot);
@@ -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 {
+55 -1
View File
@@ -116,7 +116,7 @@ export const withRemoveTag = withMutation(
asset_id: assetId,
item_type: itemType,
},
o3timisticResponse: {
optimisticResponse: {
removeTag: {
__typename: 'ModifyTagResponse',
errors: null,
@@ -242,6 +242,18 @@ export const withUnsuspendUser = withMutation(
}
);
const SetUsernameStatusFragment = gql`
fragment Talk_SetUsernameStatus on User {
state {
status {
username {
status
}
}
}
}
`;
export const withApproveUsername = withMutation(
gql`
mutation ApproveUsername($id: ID!) {
@@ -257,6 +269,27 @@ export const withApproveUsername = withMutation(
variables: {
id,
},
update: proxy => {
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
state: {
__typename: 'UserState',
status: {
__typename: 'UserStatus',
username: {
__typename: 'UsernameStatus',
status: 'APPROVED',
},
},
},
};
proxy.writeFragment({
fragment: SetUsernameStatusFragment,
id: fragmentId,
data,
});
},
});
},
}),
@@ -278,6 +311,27 @@ export const withRejectUsername = withMutation(
variables: {
id,
},
update: proxy => {
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
state: {
__typename: 'UserState',
status: {
__typename: 'UserStatus',
username: {
__typename: 'UsernameStatus',
status: 'REJECTED',
},
},
},
};
proxy.writeFragment({
fragment: SetUsernameStatusFragment,
id: fragmentId,
data,
});
},
});
},
}),
+22
View File
@@ -0,0 +1,22 @@
import t from 'coral-framework/services/i18n';
const DECIMAL_AMOUNT = 1;
export function humanizeNumber(number) {
const abs = Math.abs(number);
if (abs >= 1e9) {
number = `${(number / 1e9).toFixed(DECIMAL_AMOUNT)}${t(
'modqueue.billion'
)}`;
} else if (abs >= 1e6) {
number = `${(number / 1e6).toFixed(DECIMAL_AMOUNT)}${t(
'modqueue.million'
)}`;
} else if (abs >= 1e3) {
number = `${(number / 1e3).toFixed(DECIMAL_AMOUNT)}${t(
'modqueue.thousand'
)}`;
}
return number;
}
@@ -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;
+4 -1
View File
@@ -2,5 +2,8 @@ import { connect } from 'react-redux';
export default (mapStateToProps, ...rest) => BaseComponent => {
BaseComponent.mapStateToProps = mapStateToProps;
return connect(mapStateToProps, ...rest)(BaseComponent);
return connect(
mapStateToProps,
...rest
)(BaseComponent);
};
@@ -95,7 +95,10 @@ const mapDispatchToProps = dispatch =>
bindActionCreators({ updateUsername, updateStatus }, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
connect(
mapStateToProps,
mapDispatchToProps
),
withSetUsernameMutation,
withSetUsername
);
+4 -1
View File
@@ -121,4 +121,7 @@ const withSignUp = hoistStatics(WrappedComponent => {
return WithSignUp;
});
export default compose(withSettingsQuery, withSignUp);
export default compose(
withSettingsQuery,
withSignUp
);
@@ -198,5 +198,11 @@ const mapStateToProps = state => ({
* })(MyComponent);
*/
export default settings => {
return compose(connect(mapStateToProps, null), createHOC(settings));
return compose(
connect(
mapStateToProps,
null
),
createHOC(settings)
);
};
+91
View File
@@ -0,0 +1,91 @@
import t from 'coral-framework/services/i18n';
/**
* createNotificationService returns a notification services based on pym.
* @param {func} condition callback that checks that the given argument is valid.
* @param {error} string error that is displayed when validation fails.
* @return {func} validator function
*/
export function createValidator(condition, error) {
return (v, values) => (condition(v, values) ? undefined : error);
}
/**
* composeValidators chains validators and runs them in sequence until
* one validator fails and returns an error message.
* @param {func[]} validators array of validator functions.
* @return {func} validator fuction
*/
export function composeValidators(...validators) {
return value =>
validators.reduce(
(error, validator) => error || validator(value),
undefined
);
}
/**
* required checks that the value is truthy.
* @return {func} validator fuction
*/
export const required = createValidator(v => !!v, t('validators.required'));
/**
* Confirm will check that this field has the same value as another.
* @param {string} key key of other form field to compare with.
* @param {error} string error that is displayed when validation fails.
* @return {func} validator fuction
*/
export const confirm = (key, error) =>
createValidator((v, data) => v === data[key], error);
/**
* Verify email.
* @return {func} validator fuction
*/
export const verifyEmail = createValidator(
email => /^.+@.+\..+$/.test(email),
t('validators.verify_email')
);
/**
* Confirm email.
* @param {string} key key of other form field to compare with.
* @return {func} validator fuction
*/
export const confirmEmail = key => confirm(key, t('validators.confirm_email'));
/**
* Verify password.
* @return {func} validator fuction
*/
export const verifyPassword = createValidator(
pass => /^(?=.{8,}).*$/.test(pass),
t('validators.verify_password')
);
/**
* Confirm password.
* @param {string} key key of other form field to compare with.
* @return {func} validator fuction
*/
export const confirmPassword = key =>
confirm(key, t('validators.confirm_password'));
/**
* Verify username.
* @return {func} validator fuction
*/
export const verifyUsername = createValidator(
username => /^[a-zA-Z0-9_]+$/.test(username),
t('validators.verify_username')
);
/**
* Verify organization name.
* @return {func} validator fuction
*/
export const verifyOrganizationName = createValidator(
org => /^[a-zA-Z0-9_ ]+$/.test(org),
t('validators.verify_organization_name')
);
+5 -1
View File
@@ -9,7 +9,11 @@ export function createReduxEmitter(eventEmitter) {
// Handle apollo actions.
if (action.type.startsWith('APOLLO_')) {
if (action.type === 'APOLLO_SUBSCRIPTION_RESULT') {
const { operationName, variables, result: { data } } = action;
const {
operationName,
variables,
result: { data },
} = action;
eventEmitter.emit(`subscription.${operationName}.data`, {
variables,
data,

Some files were not shown because too many files have changed in this diff Show More