Add localization (#602)

* #10 add localization

* #10 revert locale from dev page

* #10 add more examples

* #10 localize auth panel

* #10 localize some comment message

* #10 localize some comment form message

* #10 translate vote messages

* #10 translate comment message

* #10 use messages value for translate reference

* #10 fix compile issue

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 translate comment message

* #10 translate comment form

* #10 translate comment form

* #10 translate root component

* #10 translate user info

* #10 translate settings

* #10 translate settings

* #10 translate toolbar

* #10 translate errors messages

* #10 sort dict

* #10 fix after rebase

* #10 localize anonymousLoginForm

* #10 localize emailLoginForm

* #10 update size-limit

* #10 localize subscribe by rss

* #10 localize subscribe by email

* #10 add de locale

* #10 increase limit size

* #10 auto generate loadLocale

* #10 add some documentation

* #10 fix error messages

* fix typo

* don't bundle en locale

* fix message id

* change size limits

* Update extract message pattern

Co-Authored-By: Pavel Mineev <pavel@mineev.me>

Co-authored-by: Pavel Mineev <pavel@mineev.me>
This commit is contained in:
Umputun
2020-03-04 14:56:00 -06:00
committed by GitHub
co-authored by Pavel Mineev
parent 5039e8d277
commit b22cd5f76a
49 changed files with 2640 additions and 464 deletions
+1 -1
View File
@@ -21,4 +21,4 @@ debug.test
*.test
remark42
/backend/var/
compose-private-backend.yml
compose-private-backend.yml
+8 -1
View File
@@ -434,7 +434,8 @@ Add this snippet to the bottom of web page:
// in well defined order
max_shown_comments: 10, // optional param; if it isn't defined default value (15) will be used
theme: 'dark', // optional param; if it isn't defined default value ('light') will be used
page_title: 'Moving to Remark42' // optional param; if it isn't defined `document.title` will be used
page_title: 'Moving to Remark42', // optional param; if it isn't defined `document.title` will be used
locale: 'en' // set up locale and language, if it isn't defined default value ('en') will be used
};
(function(c) {
@@ -469,6 +470,12 @@ Just call this function and pass a name of the theme that you want to turn on:
window.REMARK42.changeTheme('light');
```
##### Locales
Right now Remark has support three locales en, ru (partial translated), de(not translated).
You can pick one using configuration object.
Do you want support other locale? Please create [issue](https://github.com/umputun/remark42/issues).
#### Last comments
It's a widget which renders list of last comments from your site.
+1
View File
@@ -1 +1,2 @@
.env
extracted-messages
+2 -2
View File
@@ -4,11 +4,11 @@ module.exports = [
limit: '2.55 KB',
},
{
limit: '68 KB',
limit: '83 KB',
path: 'public/remark.js',
},
{
limit: '30 KB',
limit: '45 KB',
path: 'public/last-comments.js',
},
{
+24 -17
View File
@@ -2,32 +2,39 @@
### Code Style
* project uses typescript to statically analyze code
* project uses `eslint` to check frontend code. You can manually run via `npm run lint`.
* git hooks (via husky) installed automatically on `npm install` and check and try to fix code style if possible, otherwise commit will be rejected
* if you want IDE integration, you need `eslint` plugin to be installed.
- project uses typescript to statically analyze code
- project uses `eslint` to check frontend code. You can manually run via `npm run lint`.
- git hooks (via husky) installed automatically on `npm install` and check and try to fix code style if possible, otherwise commit will be rejected
- if you want IDE integration, you need `eslint` plugin to be installed.
### CSS Styles
* although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function.
* component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`.
* component base style resides in the component's root directory with name of component converted to kebab-case. For example `ListComments` style is located in `./app/components/list-comments/list-comments/scss`
* component's element style resides in its own subdirectory, with name consisting of full elements selector, for example `ListComments` `item` element is placed in `__item` directory under name `./list-comments__item.scss`
* each style should be `require`d in `index.ts` of component's root directory
- although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function.
- component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`.
- component base style resides in the component's root directory with name of component converted to kebab-case. For example `ListComments` style is located in `./app/components/list-comments/list-comments/scss`
- component's element style resides in its own subdirectory, with name consisting of full elements selector, for example `ListComments` `item` element is placed in `__item` directory under name `./list-comments__item.scss`
- each style should be `require`d in `index.ts` of component's root directory
### Imports
* imports for typescript, javascript files should be without extension: `./index`, not `./index.ts`
* if file resides in same directory or in subdirectory import should be relative: `./types/something`
* otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/app/common/store.ts` in webpack, tsconfig and jest
- imports for typescript, javascript files should be without extension: `./index`, not `./index.ts`
- if file resides in same directory or in subdirectory import should be relative: `./types/something`
- otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/app/common/store.ts` in webpack, tsconfig and jest
### Testing
* project uses `jest` as test harness.
* jest check files that match regex `\.test\.(j|t)s(x?)$`, i.e `comment.test.tsx`, `comment.test.js`
* tests are running on push attempt
* example tests can be found in `./app/store/user/reducers.test.ts`, `./app/components/auth-panel/auth-panel.test.tsx`
- project uses `jest` as test harness.
- jest check files that match regex `\.test\.(j|t)s(x?)$`, i.e `comment.test.tsx`, `comment.test.js`
- tests are running on push attempt
- example tests can be found in `./app/store/user/reducers.test.ts`, `./app/components/auth-panel/auth-panel.test.tsx`
### how to add new locale.
- add new item to `./tasks/supportedLocales.json`
- run `npm run generate-langs`
- commit all changed files
- translate all string in new generated dictionary `./app/locale/<new-locale>.json`
### Notes
* Frontend part being bundled on docker env gets placed on `/src/web` and is available via `http://{host}/web`. for example `embed.js` entry point will be available at `http://{host}/web/embed.js`
- Frontend part being bundled on docker env gets placed on `/src/web` and is available via `http://{host}/web`. for example `embed.js` entry point will be available at `http://{host}/web/embed.js`
+2
View File
@@ -16,10 +16,12 @@ export interface CommentsConfig {
theme?: Theme;
page_title?: string;
node?: string;
locale?: string;
}
export interface LastCommentsConfig {
host: string;
site_id: string;
max_last_comments: number;
locale?: string;
}
+1 -20
View File
@@ -1,4 +1,4 @@
import { Sorting, AuthProvider, BlockingDuration, Theme } from './types';
import { Sorting, AuthProvider, Theme } from './types';
import * as configConstant from './constants.config';
export const BASE_URL = configConstant.BASE_URL;
@@ -33,25 +33,6 @@ export const LS_HIDDEN_USERS_KEY = '__remarkHiddenUsers';
/** cookie key under which sort preference resides */
export const COOKIE_SORT_KEY = 'remarkSort';
export const BLOCKING_DURATIONS: BlockingDuration[] = [
{
label: 'Permanently',
value: 'permanently',
},
{
label: 'For a month',
value: '43200m',
},
{
label: 'For a week',
value: '10080m',
},
{
label: 'For a day',
value: '1440m',
},
];
export const THEMES: Theme[] = ['light', 'dark'];
export const IS_MOBILE = /Android|webOS|iPhone|iPad|iPod|Opera Mini|Windows Phone/i.test(navigator.userAgent);
+2 -4
View File
@@ -56,9 +56,8 @@ describe('fetcher', () => {
fail(data);
})
.catch(e => {
expect(e.code).toBe(-1);
expect(e.code).toBe(401);
expect(e.error).toBe('Not authorized.');
expect(e.details).toBe('Not authorized.');
});
});
it('should throw "Something went wrong." object on unknown status', async () => {
@@ -77,9 +76,8 @@ describe('fetcher', () => {
fail(data);
})
.catch(e => {
expect(e.code).toBe(-1);
expect(e.code).toBe(0);
expect(e.error).toBe('Something went wrong.');
expect(e.details).toBe('you given me something wrong');
});
});
});
+43 -35
View File
@@ -2,7 +2,7 @@ import { BASE_URL, API_BASE } from './constants';
import { siteId } from './settings';
import { StaticStore } from './static_store';
import { getCookie } from './cookies';
import { httpErrorMap } from '@app/utils/errorUtils';
import { httpErrorMap, isFailedFetch, httpMessages } from '@app/utils/errorUtils';
export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head';
const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head'];
@@ -73,46 +73,54 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
rurl += (rurl.includes('?') ? '&' : '?') + `site=${siteId}`;
}
return fetch(rurl, parameters).then(res => {
const date = (res.headers.has('date') && res.headers.get('date')) || '';
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
const timeDiff = (new Date().getTime() - timestamp) / 1000;
StaticStore.serverClientTimeDiff = timeDiff;
return fetch(rurl, parameters)
.then(res => {
const date = (res.headers.has('date') && res.headers.get('date')) || '';
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
const timeDiff = (new Date().getTime() - timestamp) / 1000;
StaticStore.serverClientTimeDiff = timeDiff;
if (res.status >= 400) {
if (httpErrorMap.has(res.status)) {
const errString = httpErrorMap.get(res.status)!;
throw {
code: -1,
error: errString,
details: errString,
};
}
return res.text().then(text => {
let err;
try {
err = JSON.parse(text);
} catch (e) {
if (logError) {
// eslint-disable-next-line no-console
console.error(err);
}
if (res.status >= 400) {
if (httpErrorMap.has(res.status)) {
const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError;
throw {
code: -1,
error: 'Something went wrong.',
details: text,
code: res.status,
error: descriptor.defaultMessage,
};
}
throw err;
});
}
return res.text().then(text => {
let err;
try {
err = JSON.parse(text);
} catch (e) {
if (logError) {
// eslint-disable-next-line no-console
console.error(err);
}
throw {
code: 0,
error: httpMessages.unexpectedError.defaultMessage,
};
}
throw err;
});
}
if (res.headers.has('Content-Type') && res.headers.get('Content-Type')!.indexOf('application/json') === 0) {
return res.json();
}
if (res.headers.has('Content-Type') && res.headers.get('Content-Type')!.indexOf('application/json') === 0) {
return res.json();
}
return res.text();
});
return res.text();
})
.catch(e => {
if (isFailedFetch(e)) {
throw {
code: -2,
error: e.message,
};
}
throw e;
});
};
return acc;
}, {}) as FetcherObject;
@@ -1,6 +1,7 @@
/** @jsx createElement */
import { createElement, Component, createRef } from 'preact';
import b from 'bem-react-helper';
import { IntlShape, defineMessages, FormattedMessage } from 'react-intl';
import { Theme } from '@app/common/types';
import { Input } from '@app/components/input';
@@ -10,6 +11,7 @@ interface Props {
onSubmit(username: string): Promise<void>;
theme: Theme;
className?: string;
intl: IntlShape;
}
interface State {
@@ -17,6 +19,22 @@ interface State {
honeyPotValue: boolean;
}
export const messages = defineMessages({
lengthLimit: {
id: 'anonymousLoginForm.length-limit',
defaultMessage: 'Username must be at least 3 characters long',
},
symbolLimit: {
id: 'anonymousLoginForm.symbol-limit',
defaultMessage:
'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces',
},
userName: {
id: 'anonymousLoginForm.user-name',
defaultMessage: 'Username',
},
});
export class AnonymousLoginForm extends Component<Props, State> {
static usernameRegex = /^[a-zA-Z][\w ]+$/;
@@ -51,9 +69,9 @@ export class AnonymousLoginForm extends Component<Props, State> {
getUsernameInvalidReason(): string | null {
const value = this.state.inputValue;
if (value.length < 3) return 'Username must be at least 3 characters long';
if (!AnonymousLoginForm.usernameRegex.test(value))
return 'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces';
const intl = this.props.intl;
if (value.length < 3) return intl.formatMessage(messages.lengthLimit);
if (!AnonymousLoginForm.usernameRegex.test(value)) return intl.formatMessage(messages.symbolLimit);
return null;
}
@@ -69,6 +87,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
render() {
const props = this.props;
const intl = props.intl;
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-panel-anonymous-login-form', {}, { theme: props.theme });
if (props.className) {
@@ -82,7 +101,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
<Input
ref={this.inputRef}
mix="auth-panel-anonymous-login-form__input"
placeholder="Username"
placeholder={intl.formatMessage(messages.userName)}
value={this.state.inputValue}
onInput={this.onChange}
/>
@@ -103,7 +122,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
title={usernameInvalidReason || ''}
disabled={usernameInvalidReason !== null}
>
Log in
<FormattedMessage id="anonymousLoginForm.log-in" defaultMessage="Log in" />
</Button>
</form>
);
@@ -1,45 +1,52 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { mount } from 'enzyme';
import { EmailLoginForm, Props, State } from './auth-panel__email-login-form';
import { mount, ReactWrapper } from 'enzyme';
import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth-panel__email-login-form';
import { User } from '@app/common/types';
import { sleep } from '@app/utils/sleep';
import { validToken } from '@app/testUtils/mocks/jwt';
import { sendEmailVerificationRequest } from '@app/common/api';
import { IntlProvider } from 'react-intl';
import enMessages from '../../../locales/en.json';
jest.mock('@app/utils/jwt', () => ({
isJwtExpired: jest
.fn()
.mockImplementationOnce(() => true)
.mockImplementationOnce(() => false)
.mockImplementationOnce(() => true),
}));
jest.mock('@app/common/api');
function simulateInput(input: ReactWrapper, value: string) {
input.getDOMNode<HTMLTextAreaElement>().value = value;
input.simulate('input');
}
describe('EmailLoginForm', () => {
const testUser = ({} as any) as User;
const onSuccess = jest.fn(async () => {});
const onSignIn = jest.fn(async () => testUser);
beforeEach(() => {
(sendEmailVerificationRequest as any).mockReset();
});
it('works', async () => {
const sendEmailVerification = jest.fn(async () => {});
(sendEmailVerificationRequest as any).mockResolvedValueOnce({});
const el = mount<Props, State>(
<EmailLoginForm
sendEmailVerification={sendEmailVerification}
onSignIn={onSignIn}
onSuccess={onSuccess}
theme="light"
/>
<IntlProvider locale="en" messages={enMessages}>
<EmailLoginForm onSignIn={onSignIn} onSuccess={onSuccess} theme="light" />
</IntlProvider>
);
await new Promise(resolve =>
el.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve)
);
simulateInput(el.find(`input[name="email"]`), 'someone@example.com');
simulateInput(el.find(`input[name="username"]`), 'someone');
el.find('form').simulate('submit');
await sleep(100);
expect(sendEmailVerification).toBeCalledWith('someone', 'someone@example.com');
expect(el.state().verificationSent).toBe(true);
await new Promise(resolve => el.setState({ tokenValue: 'abcd' } as State, resolve));
expect(sendEmailVerificationRequest).toBeCalledWith('someone', 'someone@example.com');
el.update();
simulateInput(el.find(`textarea[name="token"]`), 'abcd');
el.find('form').simulate('submit');
await sleep(100);
@@ -48,45 +55,36 @@ describe('EmailLoginForm', () => {
});
it('should send form by pasting token', async () => {
const sendEmailVerification = jest.fn(async () => {});
(sendEmailVerificationRequest as any).mockResolvedValueOnce({});
const onSignIn = jest.fn(async () => testUser);
const wrapper = mount<Props, State>(
<EmailLoginForm
sendEmailVerification={sendEmailVerification}
onSignIn={onSignIn}
onSuccess={onSuccess}
theme="light"
/>
);
await new Promise(resolve =>
wrapper.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve)
<IntlProvider locale="en" messages={enMessages}>
<EmailLoginForm onSignIn={onSignIn} onSuccess={onSuccess} theme="light" />
</IntlProvider>
);
simulateInput(wrapper.find(`input[name="email"]`), 'someone@example.com');
simulateInput(wrapper.find(`input[name="username"]`), 'someone');
wrapper.find('form').simulate('submit');
await sleep(100);
wrapper.update();
wrapper.find('textarea').getDOMNode<HTMLTextAreaElement>().value = validToken;
wrapper.find('textarea').simulate('input');
simulateInput(wrapper.find(`textarea[name="token"]`), validToken);
await sleep(100);
wrapper.update();
expect(onSignIn).toBeCalledWith(validToken);
});
it('should show error "Token is expired" on paste', async () => {
const sendEmailVerification = jest.fn(async () => {});
(sendEmailVerificationRequest as any).mockResolvedValueOnce({});
const onSignIn = jest.fn(async () => testUser);
const wrapper = mount<Props, State>(
<EmailLoginForm
sendEmailVerification={sendEmailVerification}
onSignIn={onSignIn}
onSuccess={onSuccess}
theme="light"
/>
);
await new Promise(resolve =>
wrapper.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve)
<IntlProvider locale="en" messages={enMessages}>
<EmailLoginForm onSignIn={onSignIn} onSuccess={onSuccess} theme="light" />
</IntlProvider>
);
simulateInput(wrapper.find(`input[name="email"]`), 'someone@example.com');
simulateInput(wrapper.find(`input[name="username"]`), 'someone');
wrapper.find('form').simulate('submit');
await sleep(100);
wrapper.update();
@@ -2,7 +2,6 @@
import { createElement, Component, createRef } from 'preact';
import { forwardRef } from 'preact/compat';
import b from 'bem-react-helper';
import { useSelector } from 'react-redux';
import { Theme, User } from '@app/common/types';
import { sendEmailVerificationRequest } from '@app/common/api';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
@@ -12,10 +11,9 @@ import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
import { isJwtExpired } from '@app/utils/jwt';
import { defineMessages, IntlShape, useIntl, FormattedMessage } from 'react-intl';
const mapStateToProps = () => ({
sendEmailVerification: sendEmailVerificationRequest,
});
import { messages as loginForm } from '../__anonymous-login-form/auth-panel__anonymous-login-form';
interface OwnProps {
onSignIn(token: string): Promise<User | null>;
@@ -24,7 +22,7 @@ interface OwnProps {
className?: string;
}
export type Props = OwnProps & ReturnType<typeof mapStateToProps>;
export type Props = OwnProps & { intl: IntlShape; sendEmailVerification: typeof sendEmailVerificationRequest };
export interface State {
usernameValue: string;
@@ -35,6 +33,37 @@ export interface State {
error: string | null;
}
const messages = defineMessages({
expiredToken: {
id: 'emailLoginForm.expired-token',
defaultMessage: 'Token is expired',
},
userNotFound: {
id: 'emailLoginForm.user-not-found',
defaultMessage: 'No user was found',
},
loading: {
id: 'emailLoginForm.loading',
defaultMessage: 'Loading...',
},
invalidEmail: {
id: 'emailLoginForm.invalid-email',
defaultMessage: 'Address should be valid email address',
},
emptyToken: {
id: 'emailLoginForm.empty-token',
defaultMessage: 'Token field must not be empty',
},
emailAddress: {
id: 'emailLoginForm.email-address',
defaultMessage: 'Email Address',
},
token: {
id: 'emailLoginForm.token',
defaultMessage: 'Token',
},
});
export class EmailLoginForm extends Component<Props, State> {
static usernameRegex = /^[a-zA-Z][\w ]+$/;
static emailRegex = /[^@]+@[^.]+\..+/;
@@ -70,24 +99,27 @@ export class EmailLoginForm extends Component<Props, State> {
this.tokenRef.current && this.tokenRef.current.focus();
}, 100);
} catch (e) {
this.setState({ error: extractErrorMessageFromResponse(e) });
this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) });
} finally {
this.setState({ loading: false });
}
};
async sendForm(token: string = this.state.tokenValue) {
const intl = this.props.intl;
try {
this.setState({ loading: true });
const user = await this.props.onSignIn(token);
if (!user) {
this.setState({ error: 'No user was found' });
this.setState({ error: intl.formatMessage(messages.userNotFound) });
return;
}
this.setState({ verificationSent: false, tokenValue: '' });
this.props.onSuccess && this.props.onSuccess(user);
if (this.props.onSuccess) {
await this.props.onSuccess(user);
}
} catch (e) {
this.setState({ error: extractErrorMessageFromResponse(e) });
this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) });
} finally {
this.setState({ loading: false });
}
@@ -107,13 +139,14 @@ export class EmailLoginForm extends Component<Props, State> {
};
onTokenChange = (e: Event) => {
const intl = this.props.intl;
const { value } = e.target as HTMLInputElement;
this.setState({ error: null, tokenValue: value });
try {
if (value.length > 0 && isJwtExpired(value)) {
this.setState({ error: 'Token is expired' });
this.setState({ error: intl.formatMessage(messages.expiredToken) });
return;
}
this.sendForm(value);
@@ -140,22 +173,24 @@ export class EmailLoginForm extends Component<Props, State> {
};
getForm1InvalidReason(): string | null {
if (this.state.loading) return 'Loading...';
const intl = this.props.intl;
if (this.state.loading) return intl.formatMessage(messages.loading);
const username = this.state.usernameValue;
if (username.length < 3) return 'Username must be at least 3 characters long';
if (!EmailLoginForm.usernameRegex.test(username))
return 'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces';
if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return 'Address should be valid email address';
if (username.length < 3) return intl.formatMessage(loginForm.lengthLimit);
if (!EmailLoginForm.usernameRegex.test(username)) return intl.formatMessage(loginForm.symbolLimit);
if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return intl.formatMessage(messages.invalidEmail);
return null;
}
getForm2InvalidReason(): string | null {
if (this.state.loading) return 'Loading...';
if (this.state.tokenValue.length === 0) return 'Token field must not be empty';
const intl = this.props.intl;
if (this.state.loading) return intl.formatMessage(messages.loading);
if (this.state.tokenValue.length === 0) return intl.formatMessage(messages.emptyToken);
return null;
}
render(props: Props) {
const intl = props.intl;
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-panel-email-login-form', {}, { theme: props.theme });
if (props.className) {
@@ -169,16 +204,18 @@ export class EmailLoginForm extends Component<Props, State> {
<form className={className} onSubmit={this.onVerificationSubmit}>
<Input
autoFocus
name="username"
mix="auth-panel-email-login-form__input"
ref={this.usernameInputRef}
placeholder="Username"
placeholder={intl.formatMessage(loginForm.userName)}
value={this.state.usernameValue}
onInput={this.onUsernameChange}
/>
<Input
mix="auth-panel-email-login-form__input"
type="email"
placeholder="Email Address"
name="email"
placeholder={intl.formatMessage(messages.emailAddress)}
value={this.state.addressValue}
onInput={this.onAddressChange}
/>
@@ -191,7 +228,7 @@ export class EmailLoginForm extends Component<Props, State> {
title={form1InvalidReason || ''}
disabled={form1InvalidReason !== null}
>
Send Verification
<FormattedMessage id="emailLoginForm.send-verification" defaultMessage="Send Verification" />
</Button>
</form>
);
@@ -201,13 +238,14 @@ export class EmailLoginForm extends Component<Props, State> {
return (
<form className={className} onSubmit={this.onSubmit}>
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(this.goBack)}>
Back
<FormattedMessage id="emailLoginForm.back" defaultMessage="Back" />
</Button>
<TextareaAutosize
autofocus={true}
name="token"
className="auth-panel-email-login-form__token-input"
ref={this.tokenRef}
placeholder="Token"
placeholder={intl.formatMessage(messages.token)}
value={this.state.tokenValue}
onInput={this.onTokenChange}
spellcheck={false}
@@ -222,7 +260,7 @@ export class EmailLoginForm extends Component<Props, State> {
title={form2InvalidReason || ''}
disabled={form2InvalidReason !== null}
>
Confirm
<FormattedMessage id="emailLoginForm.confirm" defaultMessage="Confirm" />
</Button>
</form>
);
@@ -232,6 +270,6 @@ export class EmailLoginForm extends Component<Props, State> {
export type EmailLoginFormRef = EmailLoginForm;
export const EmailLoginFormConnected = forwardRef<EmailLoginForm, OwnProps>((props, ref) => {
const connectedProps = useSelector(mapStateToProps);
return <EmailLoginForm {...props} {...connectedProps} ref={ref} />;
const intl = useIntl();
return <EmailLoginForm {...props} sendEmailVerification={sendEmailVerificationRequest} intl={intl} ref={ref} />;
});
@@ -5,7 +5,9 @@ import { mount } from 'enzyme';
import { Button } from '@app/components/button';
import { User, PostInfo } from '@app/common/types';
import { Props, AuthPanel } from './auth-panel';
import { Props, AuthPanelWithIntl as AuthPanel } from './auth-panel';
import { IntlProvider } from 'react-intl';
import enMessages from '../../locales/en.json';
const DefaultProps: Partial<Props> = {
sort: '-score',
@@ -22,7 +24,11 @@ const DefaultProps: Partial<Props> = {
describe('<AuthPanel />', () => {
describe('For not authorized user', () => {
it('should render login form with google and github provider', () => {
const element = mount(<AuthPanel {...(DefaultProps as Props)} user={null} />);
const element = mount(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel {...(DefaultProps as Props)} user={null} />
</IntlProvider>
);
const authPanelColumn = element.find('.auth-panel__column');
@@ -41,12 +47,14 @@ describe('<AuthPanel />', () => {
describe('sorting', () => {
it('should place selected provider first', () => {
const element = mount(
<AuthPanel
{...(DefaultProps as Props)}
providers={['google', 'github', 'yandex']}
provider={{ name: 'github' }}
user={null}
/>
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
providers={['google', 'github', 'yandex']}
provider={{ name: 'github' }}
user={null}
/>
</IntlProvider>
);
const providerLinks = element
@@ -61,12 +69,14 @@ describe('<AuthPanel />', () => {
it('should do nothing if provider not found', () => {
const element = mount(
<AuthPanel
{...(DefaultProps as Props)}
providers={['google', 'github', 'yandex']}
provider={{ name: 'baidu' }}
user={null}
/>
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
providers={['google', 'github', 'yandex']}
provider={{ name: 'baidu' }}
user={null}
/>
</IntlProvider>
);
const providerLinks = element
@@ -82,11 +92,13 @@ describe('<AuthPanel />', () => {
it('should render login form with google and github provider for read-only post', () => {
const element = mount(
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
/>
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
/>
</IntlProvider>
);
const authPanelColumn = element.find('.auth-panel__column');
@@ -105,11 +117,13 @@ describe('<AuthPanel />', () => {
it('should not render settings if there is no hidden users', () => {
const element = mount(
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
/>
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
/>
</IntlProvider>
);
const adminAction = element.find('.auth-panel__admin-action');
@@ -119,12 +133,14 @@ describe('<AuthPanel />', () => {
it('should render settings if there is some hidden users', () => {
const element = mount(
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
hiddenUsers={{ hidden_joe: {} as any }}
/>
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel
{...(DefaultProps as Props)}
user={null}
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
hiddenUsers={{ hidden_joe: {} as any }}
/>
</IntlProvider>
);
const adminAction = element.find('.auth-panel__admin-action');
@@ -134,7 +150,11 @@ describe('<AuthPanel />', () => {
});
describe('For authorized user', () => {
it('should render info about current user', () => {
const element = mount(<AuthPanel {...(DefaultProps as Props)} user={{ id: `john`, name: 'John' } as User} />);
const element = mount(
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel {...(DefaultProps as Props)} user={{ id: `john`, name: 'John' } as User} />
</IntlProvider>
);
const authPanelColumn = element.find('.auth-panel__column');
@@ -148,7 +168,9 @@ describe('<AuthPanel />', () => {
describe('For admin user', () => {
it('should render admin action', () => {
const element = mount(
<AuthPanel {...(DefaultProps as Props)} user={{ id: `test`, admin: true, name: 'John' } as User} />
<IntlProvider locale="en" messages={enMessages}>
<AuthPanel {...(DefaultProps as Props)} user={{ id: `test`, admin: true, name: 'John' } as User} />{' '}
</IntlProvider>
);
const adminAction = element.find('.auth-panel__admin-action').first();
+106 -26
View File
@@ -13,12 +13,13 @@ import { StoreState } from '@app/store';
import { ProviderState } from '@app/store/provider/reducers';
import { Dropdown, DropdownItem } from '@app/components/dropdown';
import { Button } from '@app/components/button';
import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl';
import { AnonymousLoginForm } from './__anonymous-login-form';
import { EmailLoginFormConnected } from './__email-login-form';
import { EmailLoginFormRef } from './__email-login-form/auth-panel__email-login-form';
export interface Props {
interface PropsWithoutIntl {
user: User | null;
hiddenUsers: StoreState['hiddenUsers'];
sort: Sorting;
@@ -37,6 +38,8 @@ export interface Props {
onBlockedUsersHide(): void;
}
export type Props = PropsWithoutIntl & { intl: IntlShape };
interface State {
isBlockedVisible: boolean;
anonymousUsernameInputValue: string;
@@ -44,6 +47,21 @@ interface State {
sortSelectFocused: boolean;
}
const authPanelMessages = defineMessages({
otherProvider: {
id: 'authPanel.other-provider',
defaultMessage: 'Other',
},
anonymousProvider: {
id: 'authPanel.anonymous-provider',
defaultMessage: 'Anonymous',
},
orProvider: {
id: 'authPanel.or-provider',
defaultMessage: 'or',
},
});
export class AuthPanel extends Component<Props, State> {
emailLoginRef = createRef<EmailLoginFormRef>();
@@ -155,7 +173,7 @@ export class AuthPanel extends Component<Props, State> {
return (
<div className="auth-panel__column">
You logged in as{' '}
<FormattedMessage id="authPanel.logged-as" defaultMessage="You logged in as" />{' '}
<Dropdown title={user.name} titleClass="auth-panel__user-dropdown-title" theme={theme}>
<DropdownItem separator={!isUserAnonymous}>
<div
@@ -170,13 +188,13 @@ export class AuthPanel extends Component<Props, State> {
{!isUserAnonymous && (
<DropdownItem>
<Button theme={theme} onClick={() => requestDeletion().then(onSignOut)}>
Request my data removal
<FormattedMessage id="authPanel.request-to-delete-data" defaultMessage="Request my data removal" />
</Button>
</DropdownItem>
)}
</Dropdown>{' '}
<Button kind="link" theme={theme} onClick={onSignOut}>
Logout?
<FormattedMessage id="authPanel.logout" defaultMessage="Logout?" />
</Button>
</div>
);
@@ -184,9 +202,10 @@ export class AuthPanel extends Component<Props, State> {
renderProvider = (provider: AuthProvider['name'], dropdown = false) => {
if (provider === 'anonymous') {
const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider);
return (
<Dropdown
title={PROVIDER_NAMES['anonymous']}
title={anonymous}
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
theme={this.props.theme}
>
@@ -195,6 +214,7 @@ export class AuthPanel extends Component<Props, State> {
onSubmit={this.handleAnonymousLoginFormSubmut}
theme={this.props.theme}
className="auth-panel__anonymous-login-form"
intl={this.props.intl}
/>
</DropdownItem>
</Dropdown>
@@ -234,8 +254,9 @@ export class AuthPanel extends Component<Props, State> {
};
renderOther = (providers: AuthProvider['name'][]) => {
const other = this.props.intl.formatMessage(authPanelMessages.otherProvider);
return (
<Dropdown title="Other" theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
<Dropdown title={other} theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
{providers.map(provider => (
<DropdownItem>{this.renderProvider(provider, true)}</DropdownItem>
))}
@@ -260,13 +281,13 @@ export class AuthPanel extends Component<Props, State> {
})();
const isAboveThreshold = sortedProviders.length > threshold;
const or = this.props.intl.formatMessage(authPanelMessages.orProvider);
return (
<div className="auth-panel__column">
{'Login: '}
<FormattedMessage id="authPanel.login" defaultMessage="Login:" />{' '}
{!isAboveThreshold &&
sortedProviders.map((provider, i) => {
const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ' or ' : ', ';
const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', ';
return (
<span>
@@ -288,7 +309,7 @@ export class AuthPanel extends Component<Props, State> {
})}
{isAboveThreshold && (
<span>
{' or '}
{` ${or} `}
{this.renderOther(sortedProviders.slice(threshold - 1))}
</span>
)}
@@ -300,7 +321,10 @@ export class AuthPanel extends Component<Props, State> {
if (IS_STORAGE_AVAILABLE || !IS_THIRD_PARTY) return null;
return (
<div className="auth-panel__column">
Disable third-party cookies blocking to login or open comments in{' '}
<FormattedMessage
id="authPanel.disabled-cookies"
defaultMessage="Disable third-party cookies blocking to login or open comments in"
/>{' '}
<a
className="auth-panel__pseudo-link"
href={`${window.location.origin}/web/comments.html${window.location.search}`}
@@ -314,7 +338,11 @@ export class AuthPanel extends Component<Props, State> {
renderCookiesWarning = () => {
if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) return null;
return <div className="auth-panel__column">Allow cookies to login and comment</div>;
return (
<div className="auth-panel__column">
<FormattedMessage id="authPanel.enable-cookies" defaultMessage="Allow cookies to login and comment" />
</div>
);
};
renderSettingsLabel = () => {
@@ -325,7 +353,11 @@ export class AuthPanel extends Component<Props, State> {
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
role="link"
>
{this.state.isBlockedVisible ? 'Hide' : 'Show'} settings
{this.state.isBlockedVisible ? (
<FormattedMessage id="authPanel.hide-settings" defaultMessage="Hide settings" />
) : (
<FormattedMessage id="authPanel.show-settings" defaultMessage="Show settings" />
)}
</Button>
);
};
@@ -339,7 +371,11 @@ export class AuthPanel extends Component<Props, State> {
{...getHandleClickProps(() => this.toggleCommentsAvailability())}
role="link"
>
{isCommentsDisabled ? 'Enable' : 'Disable'} comments
{isCommentsDisabled ? (
<FormattedMessage id="authPanel.enable-comments" defaultMessage="Enable comments" />
) : (
<FormattedMessage id="authPanel.disable-comments" defaultMessage="Disable comments" />
)}
</Button>
);
};
@@ -347,10 +383,10 @@ export class AuthPanel extends Component<Props, State> {
renderSort = () => {
const { sort } = this.props;
const { sortSelectFocused } = this.state;
const sortArray = getSortArray(sort);
const sortArray = getSortArray(sort, this.props.intl);
return (
<span className="auth-panel__sort">
Sort by{' '}
<FormattedMessage id="commentSort.sort-by" defaultMessage="Sort by" />{' '}
<span className="auth-panel__select-label">
<span className={b('auth-panel__select-label-value', {}, { focused: sortSelectFocused })}>
{sortArray.find(x => 'selected' in x && x.selected!)!.label}
@@ -396,7 +432,11 @@ export class AuthPanel extends Component<Props, State> {
{isAdmin && ' • '}
{!isAdmin && read_only && <span className="auth-panel__readonly-label">Read-only</span>}
{!isAdmin && read_only && (
<span className="auth-panel__readonly-label">
<FormattedMessage id="authPanel.read-only" defaultMessage="Read-only" />
</span>
)}
{this.renderSort()}
</div>
@@ -405,7 +445,42 @@ export class AuthPanel extends Component<Props, State> {
}
}
function getSortArray(currentSort: Sorting) {
const sortMessages = defineMessages({
best: {
id: 'commentsSort.best',
defaultMessage: 'Best',
},
worst: {
id: 'commentsSort.worst',
defaultMessage: 'Worst',
},
newest: {
id: 'commentsSort.newest',
defaultMessage: 'Newest',
},
oldest: {
id: 'commentsSort.oldest',
defaultMessage: 'Oldest',
},
recentlyUpdated: {
id: 'commentsSort.recently-updated',
defaultMessage: 'Recently updated',
},
leastRecentlyUpdated: {
id: 'commentsSort.least-recently-updated',
defaultMessage: 'Least recently updated',
},
mostControversial: {
id: 'commentsSort.most-controversial',
defaultMessage: 'Most controversial',
},
leastControversial: {
id: 'commentsSort.least-controversial',
defaultMessage: 'Least controversial',
},
});
function getSortArray(currentSort: Sorting, intl: IntlShape) {
const sortArray: {
value: Sorting;
label: string;
@@ -413,35 +488,35 @@ function getSortArray(currentSort: Sorting) {
}[] = [
{
value: '-score',
label: 'Best',
label: intl.formatMessage(sortMessages.best),
},
{
value: '+score',
label: 'Worst',
label: intl.formatMessage(sortMessages.worst),
},
{
value: '-time',
label: 'Newest',
label: intl.formatMessage(sortMessages.newest),
},
{
value: '+time',
label: 'Oldest',
label: intl.formatMessage(sortMessages.oldest),
},
{
value: '-active',
label: 'Recently updated',
label: intl.formatMessage(sortMessages.recentlyUpdated),
},
{
value: '+active',
label: 'Least recently updated',
label: intl.formatMessage(sortMessages.leastRecentlyUpdated),
},
{
value: '-controversy',
label: 'Most controversial',
label: intl.formatMessage(sortMessages.mostControversial),
},
{
value: '+controversy',
label: 'Least controversial',
label: intl.formatMessage(sortMessages.leastControversial),
},
];
@@ -453,3 +528,8 @@ function getSortArray(currentSort: Sorting) {
return sort;
});
}
export const AuthPanelWithIntl = (props: PropsWithoutIntl) => {
const intl = useIntl();
return <AuthPanel intl={intl} {...props} />;
};
+1 -1
View File
@@ -1,4 +1,4 @@
export { AuthPanel } from './auth-panel';
export { AuthPanelWithIntl as AuthPanel } from './auth-panel';
import './auth-panel.scss';
@@ -16,6 +16,8 @@ import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
import { Dropdown } from '@app/components/dropdown';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { IntlProvider } from 'react-intl';
import enMessages from '../../../locales/en.json';
import { SubscribeByEmail, SubscribeByEmailForm } from './';
@@ -40,9 +42,11 @@ jest.mock('@app/utils/jwt', () => ({
describe('<SubscribeByEmail/>', () => {
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
mount(
<Provider store={store}>
<SubscribeByEmail />
</Provider>
<IntlProvider locale="en" messages={enMessages}>
<Provider store={store}>
<SubscribeByEmail />
</Provider>
</IntlProvider>
);
it('should be rendered with disabled email button when user is anonymous', () => {
@@ -67,9 +71,11 @@ describe('<SubscribeByEmail/>', () => {
describe('<SubscribeByEmailForm/>', () => {
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
mount(
<Provider store={store}>
<SubscribeByEmailForm />
</Provider>
<IntlProvider locale="en" messages={enMessages}>
<Provider store={store}>
<SubscribeByEmailForm />
</Provider>
</IntlProvider>
);
it('should render email form by default', () => {
const store = mockStore(initialStore);
@@ -130,7 +136,7 @@ describe('<SubscribeByEmailForm/>', () => {
wrapper.update();
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email');
expect(wrapper.find(Button).prop('children')).toEqual('Unsubscribe');
expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
});
it('should send form by paste valid token', async () => {
@@ -156,7 +162,7 @@ describe('<SubscribeByEmailForm/>', () => {
wrapper.update();
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email');
expect(wrapper.find(Button).prop('children')).toEqual('Unsubscribe');
expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
});
it('should pass throw unsubscribe process', async () => {
@@ -175,6 +181,6 @@ describe('<SubscribeByEmailForm/>', () => {
wrapper.update();
expect(wrapper.text()).toStartWith('You have been unsubscribed by email to updates');
expect(wrapper.find(Button).prop('children')).toEqual('Close');
expect(wrapper.find(Button).text()).toEqual('Close');
});
});
@@ -23,6 +23,7 @@ import { Preloader } from '@app/components/preloader';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
import { isJwtExpired } from '@app/utils/jwt';
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl';
const emailRegex = /[^@]+@[^.]+\..+/;
@@ -35,18 +36,60 @@ enum Step {
Unsubscribed,
}
const messages = defineMessages({
haveSubscribed: {
id: 'subscribeByEmail.have-been-subscribed',
defaultMessage: 'You have been subscribed on updates by email',
},
subscribed: {
id: 'subscribeByEmail.subscribed',
defaultMessage: 'You are subscribed on updates by email',
},
submit: {
id: 'subscribeByEmail.submit',
defaultMessage: 'Submit',
},
subscribe: {
id: 'subscribeByEmail.subscribe',
defaultMessage: 'Subscribe',
},
subscribeByEmail: {
id: 'subscribeByEmail.subscribe-by-email',
defaultMessage: 'Subscribe by Email',
},
onlyRegisteredUsers: {
id: 'subscribeByEmail.only-registered-users',
defaultMessage: 'Available only for registered users',
},
expiredToken: {
id: 'subscribeByEmail.expired-token',
defaultMessage: 'Expired token',
},
token: {
id: 'subscribeByEmail.token',
defaultMessage: 'Token',
},
email: {
id: 'subscribeByEmail.email',
defaultMessage: 'Email',
},
});
const renderEmailPart = (
loading: boolean,
intl: IntlShape,
emailAddress: string,
handleChangeEmail: (e: Event) => void,
emailAddressRef: ReturnType<typeof useRef>
) => (
<Fragment>
<div className="comment-form__subscribe-by-email__title">Subscribe to replies</div>
<div className="comment-form__subscribe-by-email__title">
<FormattedMessage id="subscribeByEmail.subscribe-to-replies" defaultMessage="Subscribe to replies" />
</div>
<Input
ref={emailAddressRef}
mix="comment-form__subscribe-by-email__input"
placeholder="Email"
placeholder={intl.formatMessage(messages.email)}
value={emailAddress}
onInput={handleChangeEmail}
disabled={loading}
@@ -56,17 +99,18 @@ const renderEmailPart = (
const renderTokenPart = (
loading: boolean,
intl: IntlShape,
token: string,
handleChangeToken: (e: Event) => void,
setEmailStep: () => void
) => (
<Fragment>
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
Back
<FormattedMessage id="subscribeByEmail.back" defaultMessage="Back" />
</Button>
<TextareaAutosize
className="comment-form__subscribe-by-email__token-input"
placeholder="Token"
placeholder={intl.formatMessage(messages.token)}
autofocus
onInput={handleChangeToken}
disabled={loading}
@@ -78,6 +122,7 @@ const renderTokenPart = (
export const SubscribeByEmailForm: FunctionComponent = () => {
const theme = useTheme();
const dispatch = useDispatch();
const intl = useIntl();
const subscribed = useSelector<StoreState, boolean>(({ user }) =>
user === null ? false : Boolean(user.email_subscription)
);
@@ -114,7 +159,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
break;
}
} catch (e) {
setError(extractErrorMessageFromResponse(e));
setError(extractErrorMessageFromResponse(e, intl));
} finally {
setLoading(false);
}
@@ -139,7 +184,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
try {
if (value.length > 0 && isJwtExpired(value)) {
setError('Token is expired');
setError(intl.formatMessage(messages.expiredToken));
} else {
sendForm(value);
}
@@ -189,7 +234,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
previousStep.current = Step.Subscribed;
setStep(Step.Unsubscribed);
} catch (e) {
setError(extractErrorMessageFromResponse(e));
setError(extractErrorMessageFromResponse(e, intl));
} finally {
setLoading(false);
}
@@ -197,8 +242,8 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
const text =
previousStep.current === Step.Token
? 'You have been subscribed on updates by email'
: 'You are subscribed on updates by email';
? intl.formatMessage(messages.haveSubscribed)
: intl.formatMessage(messages.subscribed);
return (
<div className={b('comment-form__subscribe-by-email', { mods: { subscribed: true } })}>
@@ -210,7 +255,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
theme={theme}
onClick={handleUnsubscribe}
>
Unsubscribe
<FormattedMessage id="subscribeByEmail.unsubscribe" defaultMessage="Unsubscribe" />
</Button>
</div>
);
@@ -225,7 +270,10 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
return (
<div className={b('comment-form__subscribe-by-email', { mods: { unsubscribed: true } })}>
You have been unsubscribed by email to updates
<FormattedMessage
id="subscribeByEmail.have-been-unsubscribed"
defaultMessage="You have been unsubscribed by email to updates"
/>
<Button
kind="primary"
size="middle"
@@ -233,18 +281,19 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
theme={theme}
onClick={() => setStep(Step.Close)}
>
Close
<FormattedMessage id="subscribeByEmail.close" defaultMessage="Close" />
</Button>
</div>
);
}
const buttonLabel = step === Step.Email ? 'Submit' : 'Subscribe';
const buttonLabel =
step === Step.Email ? intl.formatMessage(messages.submit) : intl.formatMessage(messages.subscribe);
return (
<form className={b('comment-form__subscribe-by-email', {}, { theme })} onSubmit={handleSubmit}>
{step === Step.Email && renderEmailPart(loading, emailAddress, handleChangeEmail, emailAddressRef)}
{step === Step.Token && renderTokenPart(loading, token, handleChangeToken, setEmailStep)}
{step === Step.Email && renderEmailPart(loading, intl, emailAddress, handleChangeEmail, emailAddressRef)}
{step === Step.Token && renderTokenPart(loading, intl, token, handleChangeToken, setEmailStep)}
{error !== null && (
<div className="comment-form__subscribe-by-email__error" role="alert">
{error}
@@ -265,14 +314,17 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
export const SubscribeByEmail: FunctionComponent = () => {
const theme = useTheme();
const intl = useIntl();
const user = useSelector<StoreState, User | null>(({ user }) => user);
const isAnonymous = isUserAnonymous(user);
const buttonTitle = isAnonymous ? 'Available only for registered users' : 'Subscribe by Email';
const buttonTitle = isAnonymous
? intl.formatMessage(messages.onlyRegisteredUsers)
: intl.formatMessage(messages.subscribeByEmail);
return (
<Dropdown
mix="comment-form__email-dropdown"
title="Email"
title={intl.formatMessage(messages.email)}
theme={theme}
disabled={isAnonymous}
buttonTitle={buttonTitle}
@@ -1,6 +1,7 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { shallow } from 'enzyme';
import enMessages from '../../../locales/en.json';
import { SubscribeByRSS, createSubscribeUrl } from './';
@@ -8,6 +9,16 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(fn => fn({ theme: 'light' })),
}));
jest.mock('react-intl', () => {
// Require the original module to not be mocked...
const originalModule = jest.requireActual('react-intl');
return {
...originalModule,
useIntl: () => originalModule.createIntl({ locale: `en`, messages: enMessages }),
};
});
describe('<SubscribeByRSS/>', () => {
let wrapper: ReturnType<typeof shallow>;
@@ -16,6 +27,7 @@ describe('<SubscribeByRSS/>', () => {
});
it('should be render links in dropdown', () => {
wrapper.update();
expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3);
});
@@ -1,6 +1,7 @@
/** @jsx createElement */
import { createElement, FunctionComponent } from 'preact';
import { useMemo } from 'preact/hooks';
import { useIntl, defineMessages } from 'react-intl';
import useTheme from '@app/hooks/useTheme';
import { siteId, url } from '@app/common/settings';
@@ -10,22 +11,46 @@ import { Dropdown, DropdownItem } from '@app/components/dropdown';
export const createSubscribeUrl = (type: 'post' | 'site' | 'reply', urlParams: string = '') =>
`${BASE_URL}${API_BASE}/rss/${type}?site=${siteId}${urlParams}`;
const messages = defineMessages({
thread: {
id: 'subscribeByRSS.thread',
defaultMessage: 'Thread',
},
site: {
id: 'subscribeByRSS.site',
defaultMessage: 'Site',
},
replies: {
id: 'subscribeByRSS.replies',
defaultMessage: 'Replies',
},
buttonTitle: {
id: 'subscribeByRSS.button-title',
defaultMessage: 'Subscribe by RSS',
},
title: {
id: 'subscribeByRSS.title',
defaultMessage: 'RSS',
},
});
export const SubscribeByRSS: FunctionComponent<{ userId: string | null }> = ({ userId }) => {
const theme = useTheme();
const intl = useIntl();
const items: Array<[string, string]> = useMemo(
() => [
[createSubscribeUrl('post'), 'Thread'],
[createSubscribeUrl('site', `&user=${userId}`), 'Site'],
[createSubscribeUrl('reply', `&url=${url}`), 'Replies'],
[createSubscribeUrl('post'), intl.formatMessage(messages.thread)],
[createSubscribeUrl('site', `&user=${userId}`), intl.formatMessage(messages.site)],
[createSubscribeUrl('reply', `&url=${url}`), intl.formatMessage(messages.replies)],
],
[userId]
);
return (
<Dropdown
title="RSS"
title={intl.formatMessage(messages.title)}
titleClass="comment-form__rss-dropdown__title"
buttonTitle="Subscribe by RSS"
buttonTitle={intl.formatMessage(messages.buttonTitle)}
mix="comment-form__rss-dropdown"
theme={theme}
>
@@ -8,7 +8,7 @@ import { StaticStore } from '@app/common/static_store';
import { CommentForm, Props } from './comment-form';
import { SubscribeByEmail } from './__subscribe-by-email';
const DEFAULT_PROPS: Readonly<Props> = {
const DEFAULT_PROPS: Readonly<Omit<Props, 'intl'>> = {
mode: 'main',
theme: 'light',
onSubmit: () => Promise.resolve(),
@@ -16,9 +16,15 @@ const DEFAULT_PROPS: Readonly<Props> = {
user: null,
};
const intl = {
formatMessage() {
return '';
},
} as any;
describe('<CommentForm />', () => {
it('should render without control panel, preview button, and rss links in "simple view" mode', () => {
const props = { ...DEFAULT_PROPS, simpleView: true };
const props = { ...DEFAULT_PROPS, simpleView: true, intl };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.exists('.comment-form__control-panel')).toEqual(false);
@@ -29,7 +35,7 @@ describe('<CommentForm />', () => {
it('should be rendered with email subscription button', () => {
StaticStore.config.email_notifications = true;
const props = { ...DEFAULT_PROPS, user };
const props = { ...DEFAULT_PROPS, user, intl };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.exists(SubscribeByEmail)).toEqual(true);
@@ -38,7 +44,7 @@ describe('<CommentForm />', () => {
it('should be rendered without email subscription button when email_notifications disabled', () => {
StaticStore.config.email_notifications = false;
const props = { ...DEFAULT_PROPS, user };
const props = { ...DEFAULT_PROPS, user, intl };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.exists(SubscribeByEmail)).toEqual(false);
@@ -1,5 +1,6 @@
/** @jsx createElement */
import { createElement, Component, createRef, Fragment } from 'preact';
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
import b, { Mix } from 'bem-react-helper';
import { User, Theme, Image, ApiError } from '@app/common/types';
@@ -34,6 +35,7 @@ export interface Props {
/** action on cancel. optional as root input has no cancel option */
onCancel?: () => void;
uploadImage?: (image: File) => Promise<Image>;
intl: IntlShape;
}
interface State {
@@ -51,14 +53,39 @@ interface State {
buttonText: null | string;
}
const Labels = {
main: 'Send',
edit: 'Save',
reply: 'Reply',
};
const ImageMimeRegex = /image\//i;
const messages = defineMessages({
placeholder: {
id: 'commentForm.input-placeholder',
defaultMessage: 'Your comment here',
},
uploadFileFail: {
id: 'commentForm.upload-file-fail',
defaultMessage: '{fileName} upload failed with "{errorMessage}"',
},
uploading: {
id: 'commentForm.uploading',
defaultMessage: 'Uploading...',
},
uploadingFile: {
id: 'commentForm.uploading-file',
defaultMessage: 'uploading {fileName}...',
},
exceededSize: {
id: 'commentForm.exceeded-size',
defaultMessage: '{fileName} exceeds size limit of {maxImageSize}',
},
newComment: {
id: 'commentForm.new-comment',
defaultMessage: 'New comment',
},
unexpectedError: {
id: 'commentForm.unexpected-error',
defaultMessage: 'Something went wrong. Please try again a bit later.',
},
});
export class CommentForm extends Component<Props, State> {
/** reference to textarea element */
textAreaRef = createRef<TextareaAutosize>();
@@ -165,8 +192,7 @@ export class CommentForm extends Component<Props, State> {
this.setState({ preview: null, text: '' });
})
.catch(e => {
console.error(e); // eslint-disable-line no-console
const errorMessage = extractErrorMessageFromResponse(e);
const errorMessage = extractErrorMessageFromResponse(e, this.props.intl);
this.setState({ isErrorShown: true, errorMessage });
})
.finally(() => this.setState({ isDisabled: false }));
@@ -228,18 +254,20 @@ export class CommentForm extends Component<Props, State> {
/** wrapper with error handling for props.uploadImage */
uploadImage(file: File): Promise<Image | Error> {
return this.props.uploadImage!(file).catch(
(e: ApiError | string) =>
new Error(
typeof e === 'string'
? `${file.name} upload failed with "${e}"`
: `${file.name} upload failed with "${e.error}"`
)
);
const intl = this.props.intl;
return this.props.uploadImage!(file).catch((e: ApiError | string) => {
return new Error(
intl.formatMessage(messages.uploadFileFail, {
fileName: file.name,
errorMessage: extractErrorMessageFromResponse(e, this.props.intl),
})
);
});
}
/** performs upload process */
async uploadImages(files: File[]) {
const intl = this.props.intl;
if (!this.props.uploadImage) return;
if (!this.textAreaRef.current) return;
@@ -255,7 +283,7 @@ export class CommentForm extends Component<Props, State> {
errorMessage: null,
isErrorShown: false,
isDisabled: true,
buttonText: 'Uploading...',
buttonText: intl.formatMessage(messages.uploading),
});
// fallback for ie < 9
@@ -266,7 +294,12 @@ export class CommentForm extends Component<Props, State> {
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
if (file.size > StaticStore.config.max_image_size) {
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
this.appendError(
intl.formatMessage(messages.exceededSize, {
fileName: file.name,
maxImageSize: maxImageSizeString,
})
);
continue;
}
@@ -294,7 +327,9 @@ export class CommentForm extends Component<Props, State> {
const isFirst = i === 0;
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
const uploadPlaceholder = `${placeholderStart}![uploading ${file.name}...]()`;
const uploadPlaceholder = `${placeholderStart}![${intl.formatMessage(messages.uploadingFile, {
fileName: file.name,
})}]()`;
const uploadPlaceholderLength = uploadPlaceholder.length;
const selection = this.textAreaRef.current.getSelection();
/** saved selection in case of error */
@@ -309,7 +344,12 @@ export class CommentForm extends Component<Props, State> {
};
if (file.size > StaticStore.config.max_image_size) {
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
this.appendError(
intl.formatMessage(messages.exceededSize, {
fileName: file.name,
maxImageSize: maxImageSizeString,
})
);
continue;
}
@@ -343,8 +383,14 @@ export class CommentForm extends Component<Props, State> {
render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) {
const charactersLeft = maxLength - text.length;
errorMessage = props.errorMessage || errorMessage;
const Labels = {
main: <FormattedMessage id="commentForm.send" defaultMessage="Send" />,
edit: <FormattedMessage id="commentForm.save" defaultMessage="Save" />,
reply: <FormattedMessage id="commentForm.reply" defaultMessage="Reply" />,
};
const label = buttonText || Labels[props.mode || 'main'];
const intl = this.props.intl;
const placeholderMessage = intl.formatMessage(messages.placeholder);
return (
<form
className={b('comment-form', {
@@ -356,13 +402,14 @@ export class CommentForm extends Component<Props, State> {
mix: props.mix,
})}
onSubmit={this.send}
aria-label="New comment"
aria-label={intl.formatMessage(messages.newComment)}
onDragOver={this.onDragOver}
onDrop={this.onDrop}
>
{!props.simpleView && (
<div className="comment-form__control-panel">
<MarkdownToolbar
intl={intl}
allowUpload={Boolean(this.props.uploadImage)}
uploadImages={this.uploadImages}
textareaId={this.textareaId}
@@ -376,7 +423,7 @@ export class CommentForm extends Component<Props, State> {
onPaste={this.onPaste}
ref={this.textAreaRef}
className="comment-form__field"
placeholder="Your comment here"
placeholder={placeholderMessage}
value={text}
maxLength={maxLength}
onInput={this.onInput}
@@ -390,7 +437,7 @@ export class CommentForm extends Component<Props, State> {
</div>
{(isErrorShown || !!errorMessage) &&
(errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => (
(errorMessage || intl.formatMessage(messages.unexpectedError)).split('\n').map(e => (
<p className="comment-form__error" role="alert" key={e}>
{e}
</p>
@@ -406,7 +453,7 @@ export class CommentForm extends Component<Props, State> {
disabled={isDisabled}
onClick={this.getPreview}
>
Preview
<FormattedMessage id="commentForm.preview" defaultMessage="Preview" />
</Button>
)}
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
@@ -416,18 +463,24 @@ export class CommentForm extends Component<Props, State> {
{!props.simpleView && props.mode === 'main' && (
<div className="comment-form__rss">
<div className="comment-form__markdown">
Styling with{' '}
<a className="comment-form__markdown-link" target="_blank" href="markdown-help.html">
Markdown
</a>
{' is supported'}
<FormattedMessage
id="commentForm.notice-about-styling"
defaultMessage="Styling with <a>Markdown</a> is supported"
values={{
a: (title: string) => (
<a class="comment-form__markdown-link" target="_blank" href="markdown-help.html">
{title}
</a>
),
}}
/>
</div>
{'Subscribe by '}
<FormattedMessage id="commentForm.subscribe-by" defaultMessage="Subscribe by" />{' '}
<SubscribeByRSS userId={props.user !== null ? props.user.id : null} />
{StaticStore.config.email_notifications && (
<Fragment>
{' or '}
<SubscribeByEmail />
{' '}
<FormattedMessage id="commentForm.subscribe-or" defaultMessage="or" /> <SubscribeByEmail />
</Fragment>
)}
</div>
@@ -439,7 +492,9 @@ export class CommentForm extends Component<Props, State> {
!!preview && (
<div className="comment-form__preview-wrapper">
<div
className={b('comment-form__preview', { mix: b('raw-content', {}, { theme: props.theme }) })}
className={b('comment-form__preview', {
mix: b('raw-content', {}, { theme: props.theme }),
})}
dangerouslySetInnerHTML={{ __html: preview }}
/>
</div>
@@ -1,6 +1,7 @@
/** @jsx createElement */
import { createElement, Component } from 'preact';
import '@github/markdown-toolbar-element';
import { defineMessages, IntlShape } from 'react-intl';
import BoldIcon from './markdown-toolbar-icons/bold-icon';
import HeaderIcon from './markdown-toolbar-icons/header-icon';
import ItalicIcon from './markdown-toolbar-icons/italic-icon';
@@ -12,6 +13,7 @@ import UnorderedListIcon from './markdown-toolbar-icons/unordered-list-icon';
import OrderedListIcon from './markdown-toolbar-icons/ordered-list-icon';
interface Props {
intl: IntlShape;
textareaId: string;
uploadImages: (files: File[]) => Promise<void>;
allowUpload: boolean;
@@ -26,15 +28,44 @@ interface FileInputEvent extends Event {
readonly currentTarget: FileEventTarget | null;
}
const boldLabel = 'Add bold text <cmd-b>';
const headerLabel = 'Add header text';
const italicLabel = 'Add italic text <cmd-i>';
const quoteLabel = 'Insert a quote';
const codeLabel = 'Insert a code';
const linkLabel = 'Add a link <cmd-k>';
const unorderedListLabel = 'Add a bulleted list';
const orderedListLabel = 'Add a numbered list';
const attachImageLabel = 'Attach the image, drag & drop or paste from clipboard';
const messages = defineMessages({
bold: {
id: 'toolbar.bold',
defaultMessage: 'Add bold text <cmd-b>',
},
header: {
id: 'toolbar.header',
defaultMessage: 'Add header text',
},
italic: {
id: 'toolbar.italic',
defaultMessage: 'Add italic text <cmd-i>',
},
quote: {
id: 'toolbar.quote',
defaultMessage: 'Insert a quote',
},
code: {
id: 'toolbar.code',
defaultMessage: 'Insert a code',
},
link: {
id: 'toolbar.link',
defaultMessage: 'Add a link <cmd-k>',
},
unorderedList: {
id: 'toolbar.unordered-list',
defaultMessage: 'Add a bulleted list',
},
orderedList: {
id: 'toolbar.ordered-list',
defaultMessage: 'Add a numbered list',
},
attachImage: {
id: 'toolbar.attach-image',
defaultMessage: 'Attach the image, drag & drop or paste from clipboard',
},
});
export default class MarkdownToolbar extends Component<Props> {
constructor(props: Props) {
@@ -49,6 +80,18 @@ export default class MarkdownToolbar extends Component<Props> {
currentTarget.value = null;
}
render(props: Props) {
const intl = props.intl;
const boldLabel = intl.formatMessage(messages.bold);
const headerLabel = intl.formatMessage(messages.header);
const italicLabel = intl.formatMessage(messages.italic);
const quoteLabel = intl.formatMessage(messages.quote);
const codeLabel = intl.formatMessage(messages.code);
const linkLabel = intl.formatMessage(messages.link);
const unorderedListLabel = intl.formatMessage(messages.unorderedList);
const orderedListLabel = intl.formatMessage(messages.orderedList);
const attachImageLabel = intl.formatMessage(messages.attachImage);
return (
<markdown-toolbar className="comment-form__toolbar" for={props.textareaId}>
<div className="comment-form__toolbar-group">
@@ -1,10 +1,19 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { mount, shallow } from 'enzyme';
import { mount as enzymeMount } from 'enzyme';
import { Props, Comment } from './comment';
import { User, Comment as CommentType, PostInfo } from '@app/common/types';
import { sleep } from '@app/utils/sleep';
import { StaticStore } from '@app/common/static_store';
import { IntlProvider } from 'react-intl';
import enMessages from '../../locales/en.json';
const mount = (component: any) =>
enzymeMount(
<IntlProvider locale="en" messages={enMessages}>
{component}
</IntlProvider>
);
const DefaultProps: Partial<Props> = {
CommentForm: null,
@@ -19,6 +28,7 @@ const DefaultProps: Partial<Props> = {
id: 'someone',
picture: 'somepicture-url',
},
time: new Date().toString(),
locator: {
url: 'somelocatorurl',
site: 'remark',
@@ -35,7 +45,7 @@ describe('<Comment />', () => {
describe('voting', () => {
it('should be disabled for an anonymous user', () => {
const props = { ...DefaultProps, user: { id: 'anonymous_1' } } as Props;
const wrapper = shallow(<Comment {...props} />);
const wrapper = mount(<Comment {...props} />);
const voteButtons = wrapper.find('.comment__vote');
expect(voteButtons.length).toEqual(2);
@@ -50,7 +60,7 @@ describe('<Comment />', () => {
StaticStore.config.anon_vote = true;
const props = { ...DefaultProps, user: { id: 'anonymous_1' } } as Props;
const wrapper = shallow(<Comment {...props} />);
const wrapper = mount(<Comment {...props} />);
const voteButtons = wrapper.find('.comment__vote');
expect(voteButtons.length).toEqual(2);
@@ -271,10 +281,14 @@ describe('<Comment />', () => {
repliesCount: 0,
};
StaticStore.config.edit_duration = 300;
const WrappedComponent = (props: Props) => (
<IntlProvider locale="en" messages={enMessages}>
<Comment {...props} />
</IntlProvider>
);
const component = enzymeMount(<WrappedComponent {...(props as Props)} />);
const component = shallow(<Comment {...(props as Props)} />);
expect((component.state('editDeadline') as Date).getTime()).toBe(
expect((component.find(`Comment`).state('editDeadline') as Date).getTime()).toBe(
new Date(new Date(initTime).getTime() + 300 * 1000).getTime()
);
@@ -282,7 +296,7 @@ describe('<Comment />', () => {
data: { ...props.data, time: changedTime },
});
expect((component.state('editDeadline') as Date).getTime()).toBe(
expect((component.find(`Comment`).state('editDeadline') as Date).getTime()).toBe(
new Date(new Date(changedTime).getTime() + 300 * 1000).getTime()
);
});
@@ -302,9 +316,9 @@ describe('<Comment />', () => {
};
StaticStore.config.edit_duration = 300;
const component = shallow(<Comment {...(props as Props)} />);
const component = mount(<Comment {...(props as Props)} />);
expect(component.state('editDeadline')).toBe(null);
expect(component.find('Comment').state('editDeadline')).toBe(null);
});
});
});
+199 -67
View File
@@ -6,7 +6,7 @@ import { createElement, JSX, Component, createRef, ComponentType } from 'preact'
import b from 'bem-react-helper';
import { getHandleClickProps } from '@app/common/accessibility';
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS } from '@app/common/constants';
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX } from '@app/common/constants';
import { StaticStore } from '@app/common/static_store';
import debounce from '@app/utils/debounce';
@@ -22,8 +22,74 @@ import Countdown from '@app/components/countdown';
import { boundActions } from './connected-comment';
import { getPreview, uploadImage } from '@app/common/api';
import postMessage from '@app/utils/postMessage';
import { FormattedMessage, useIntl, IntlShape, defineMessages } from 'react-intl';
import { getVoteMessage, VoteMessagesTypes } from './getVoteMessage';
import { getBlockingDurations } from './getBlockingDurations';
export type Props = {
const messages = defineMessages({
deleteMessage: {
id: 'comment.delete-message',
defaultMessage: 'Do you want to delete this comment?',
},
hideUserComments: {
id: 'comment.hide-user-comment',
defaultMessage: 'Do you want to hide comments of {userName}?',
},
pinComment: {
id: 'comment.pin-comment',
defaultMessage: 'Do you want to pin this comment?',
},
unpinComment: {
id: 'comment.unpin-comment',
defaultMessage: 'Do you want to unpin this comment?',
},
verifyUser: {
id: 'comment.verify-user',
defaultMessage: 'Do you want to verify {userName}?',
},
unverifyUser: {
id: 'comment.unverify-user',
defaultMessage: 'Do you want to unverify {userName}?',
},
blockUser: {
id: 'comment.block-user',
defaultMessage: 'Do you want to block {userName} {duration}?',
},
unblockUser: {
id: 'comment.unblock-user',
defaultMessage: 'Do you want to unblock this user?',
},
deletedComment: {
id: 'comment.deleted-comment',
defaultMessage: 'This comment was deleted',
},
controversy: {
id: 'comment.controversy',
defaultMessage: 'Controversy: {value}',
},
toggleVerification: {
id: 'comment.toggle-verification',
defaultMessage: 'Toggle verification',
},
verifiedUser: {
id: 'comment.verified-user',
defaultMessage: 'Verified user',
},
unverifiedUser: {
id: 'comment.unverified-user',
defaultMessage: 'Unverified user',
},
goToParent: {
id: 'comment.go-to-parent',
defaultMessage: 'Go to parent comment',
},
expiredTime: {
id: 'comment.expired-time',
defaultMessage: 'Editing time has expired.',
},
});
type PropsWithoutIntl = {
user: User | null;
CommentForm: ComponentType<CommentFormProps> | null;
data: CommentType;
@@ -52,6 +118,8 @@ export type Props = {
uploadImage?: typeof uploadImage;
} & Partial<typeof boundActions>;
export type Props = PropsWithoutIntl & { intl: IntlShape };
export interface State {
renderDummy: boolean;
isCopied: boolean;
@@ -72,7 +140,7 @@ export interface State {
initial: boolean;
}
export class Comment extends Component<Props, State> {
class Comment extends Component<Props, State> {
votingPromise: Promise<unknown>;
/** comment text node. Used in comment text copying */
textNode = createRef<HTMLDivElement>();
@@ -166,7 +234,8 @@ export class Comment extends Component<Props, State> {
togglePin = () => {
const value = !this.props.data.pin;
const promptMessage = `Do you want to ${value ? 'pin' : 'unpin'} this comment?`;
const intl = this.props.intl;
const promptMessage = value ? intl.formatMessage(messages.pinComment) : intl.formatMessage(messages.unpinComment);
if (confirm(promptMessage)) {
this.props.setPinState!(this.props.data.id, value);
@@ -176,7 +245,11 @@ export class Comment extends Component<Props, State> {
toggleVerify = () => {
const value = !this.props.data.user.verified;
const userId = this.props.data.user.id;
const promptMessage = `Do you want to ${value ? 'verify' : 'unverify'} ${this.props.data.user.name}?`;
const intl = this.props.intl;
const userName = this.props.data.user.name;
const promptMessage = value
? intl.formatMessage(messages.verifyUser, { userName })
: intl.formatMessage(messages.unverifyUser, { userName });
if (confirm(promptMessage)) {
this.props.setVerifyStatus!(userId, value);
@@ -197,30 +270,34 @@ export class Comment extends Component<Props, State> {
blockUser = (ttl: BlockTTL) => {
const { user } = this.props.data;
const block_duration = BLOCKING_DURATIONS.find(el => el.value === ttl);
const blockingDurations = getBlockingDurations(this.props.intl);
const blockDuration = blockingDurations.find(el => el.value === ttl);
// blocking duration may be undefined if user hasn't selected anything
// and ttl equals "Blocking period"
if (!block_duration) return;
if (!blockDuration) return;
const duration = block_duration.label;
if (confirm(`Do you want to block ${user.name} ${duration.toLowerCase()}?`)) {
const duration = blockDuration.label;
const blockUser = this.props.intl.formatMessage(messages.blockUser, {
userName: user.name,
duration: duration.toLowerCase(),
});
if (confirm(blockUser)) {
this.props.blockUser!(user.id, user.name, ttl);
}
};
onUnblockUserClick = () => {
const { user } = this.props.data;
const unblockUser = this.props.intl.formatMessage(messages.unblockUser);
const promptMessage = `Do you want to unblock this user?`;
if (confirm(promptMessage)) {
if (confirm(unblockUser)) {
this.props.unblockUser!(user.id);
}
};
deleteComment = () => {
if (confirm('Do you want to delete this comment?')) {
const deleteComment = this.props.intl.formatMessage(messages.deleteMessage);
if (confirm(deleteComment)) {
this.props.setReplyEditState!({ id: this.props.data.id, state: CommentMode.None });
this.props.removeComment!(this.props.data.id);
@@ -228,7 +305,10 @@ export class Comment extends Component<Props, State> {
};
hideUser = () => {
if (!confirm(`Do you want to hide comments of ${this.props.data.user.name}?`)) return;
const hideUserComment = this.props.intl.formatMessage(messages.hideUserComments, {
userName: this.props.data.user.name,
});
if (!confirm(hideUserComment)) return;
this.props.hideUser!(this.props.data.user);
};
@@ -236,7 +316,7 @@ export class Comment extends Component<Props, State> {
this.setState({
scoreDelta: originalDelta,
cachedScore: originalScore,
voteErrorMessage: extractErrorMessageFromResponse(e),
voteErrorMessage: extractErrorMessageFromResponse(e, this.props.intl),
});
};
@@ -349,13 +429,16 @@ export class Comment extends Component<Props, State> {
* returns reason for disabled downvoting
*/
getDownvoteDisabledReason = (): string | null => {
if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page";
if (this.props.post_info!.read_only) return "Can't vote on read-only topics";
if (this.props.data.delete) return "Can't vote for deleted comment";
if (this.isCurrentUser()) return "Can't vote for your own comment";
if (StaticStore.config.positive_score && this.props.data.score < 1) return 'Only positive score allowed';
if (this.isGuest()) return 'Sign in to vote';
if (this.isAnonymous() && !StaticStore.config.anon_vote) return "Anonymous users can't vote";
const intl = this.props.intl;
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
if (this.props.post_info!.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
if (StaticStore.config.positive_score && this.props.data.score < 1)
return getVoteMessage(VoteMessagesTypes.ONLY_POSITIVE, intl);
if (this.isGuest()) return getVoteMessage(VoteMessagesTypes.GUEST, intl);
if (this.isAnonymous() && !StaticStore.config.anon_vote) return getVoteMessage(VoteMessagesTypes.ANONYMOUS, intl);
return null;
};
@@ -363,12 +446,14 @@ export class Comment extends Component<Props, State> {
* returns reason for disabled upvoting
*/
getUpvoteDisabledReason = (): string | null => {
if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page";
if (this.props.post_info!.read_only) return "Can't vote on read-only topics";
if (this.props.data.delete) return "Can't vote for deleted comment";
if (this.isCurrentUser()) return "Can't vote for your own comment";
if (this.isGuest()) return 'Sign in to vote';
if (this.isAnonymous() && !StaticStore.config.anon_vote) return "Anonymous users can't vote";
const intl = this.props.intl;
if (!(this.props.view === 'main' || this.props.view === 'pinned'))
return getVoteMessage(VoteMessagesTypes.ONLY_POST_PAGE, intl);
if (this.props.post_info!.read_only) return getVoteMessage(VoteMessagesTypes.READONLY, intl);
if (this.props.data.delete) return getVoteMessage(VoteMessagesTypes.DELETED, intl);
if (this.isCurrentUser()) return getVoteMessage(VoteMessagesTypes.OWN_COMMENT, intl);
if (this.isGuest()) return getVoteMessage(VoteMessagesTypes.GUEST, intl);
if (this.isAnonymous() && !StaticStore.config.anon_vote) return getVoteMessage(VoteMessagesTypes.ANONYMOUS, intl);
return null;
};
@@ -388,17 +473,23 @@ export class Comment extends Component<Props, State> {
if (isAdmin) {
controls.push(
this.state.isCopied ? (
<span className="comment__control comment__control_view_inactive">Copied!</span>
<span className="comment__control comment__control_view_inactive">
<FormattedMessage id="comment.copied" defaultMessage="Copied!" />
</span>
) : (
<Button kind="link" {...getHandleClickProps(this.copyComment)} mix="comment__control">
Copy
<FormattedMessage id="comment.copy" defaultMessage="Copy" />
</Button>
)
);
controls.push(
<Button kind="link" {...getHandleClickProps(this.togglePin)} mix="comment__control">
{this.props.data.pin ? 'Unpin' : 'Pin'}
{this.props.data.pin ? (
<FormattedMessage id="comment.unpin" defaultMessage="Unpin" />
) : (
<FormattedMessage id="comment.pin" defaultMessage="Pin" />
)}
</Button>
);
}
@@ -406,7 +497,7 @@ export class Comment extends Component<Props, State> {
if (!isCurrentUser) {
controls.push(
<Button kind="link" {...getHandleClickProps(this.hideUser)} mix="comment__control">
Hide
<FormattedMessage id="comment.hide" defaultMessage="Hide" />
</Button>
);
}
@@ -415,21 +506,20 @@ export class Comment extends Component<Props, State> {
if (this.props.isUserBanned) {
controls.push(
<Button kind="link" {...getHandleClickProps(this.onUnblockUserClick)} mix="comment__control">
Unblock
<FormattedMessage id="comment.unblock" defaultMessage="Unblock" />
</Button>
);
}
const blockingDurations = getBlockingDurations(this.props.intl);
if (this.props.user!.id !== this.props.data.user.id && !this.props.isUserBanned) {
controls.push(
<span className="comment__control comment__control_select-label">
Block
<FormattedMessage id="comment.block" defaultMessage="Block" />
<select className="comment__control_select" onBlur={this.onBlockUserClick} onChange={this.onBlockUserClick}>
<option disabled selected value={undefined}>
{' '}
Blocking period{' '}
<FormattedMessage id="comment.blocking-period" defaultMessage="Blocking period" />
</option>
{BLOCKING_DURATIONS.map(block => (
{blockingDurations.map(block => (
<option value={block.value}>{block.label}</option>
))}
</select>
@@ -440,7 +530,7 @@ export class Comment extends Component<Props, State> {
if (!this.props.data.delete) {
controls.push(
<Button kind="link" {...getHandleClickProps(this.deleteComment)} mix="comment__control">
Delete
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
</Button>
);
}
@@ -464,22 +554,25 @@ export class Comment extends Component<Props, State> {
const scoreSignEnabled = !StaticStore.config.positive_score;
const uploadImageHandler = this.isAnonymous() ? undefined : this.props.uploadImage;
const commentControls = this.getCommentControls();
const intl = props.intl;
const CommentForm = this.props.CommentForm;
/**
* CommentType adapted for rendering
*/
const o = {
...props.data,
controversyText: `Controversy: ${(props.data.controversy || 0).toFixed(2)}`,
controversyText: intl.formatMessage(messages.controversy, {
value: (props.data.controversy || 0).toFixed(2),
}),
text:
props.view === 'preview'
? getTextSnippet(props.data.text)
: props.data.delete
? 'This comment was deleted'
? intl.formatMessage(messages.deletedComment)
: props.data.text,
time: formatTime(new Date(props.data.time)),
time: new Date(props.data.time),
orig: isEditing
? props.data.orig &&
props.data.orig.replace(/&[#A-Za-z0-9]+;/gi, entity => {
@@ -563,7 +656,7 @@ export class Comment extends Component<Props, State> {
/>
);
}
const goToParentMessage = intl.formatMessage(messages.goToParent);
return (
<article
className={b('comment', { mix: this.props.mix }, defaultMods)}
@@ -594,35 +687,50 @@ export class Comment extends Component<Props, State> {
{isAdmin && props.view !== 'user' && (
<span
{...getHandleClickProps(this.toggleVerify)}
aria-label="Toggle verification"
title={o.user.verified ? 'Verified user' : 'Unverified user'}
aria-label={intl.formatMessage(messages.toggleVerification)}
title={
o.user.verified
? intl.formatMessage(messages.verifiedUser)
: intl.formatMessage(messages.unverifiedUser)
}
className={b('comment__verification', {}, { active: o.user.verified, clickable: true })}
/>
)}
{!isAdmin && !!o.user.verified && props.view !== 'user' && (
<span title="Verified user" className={b('comment__verification', {}, { active: true })} />
<span
title={intl.formatMessage(messages.verifiedUser)}
className={b('comment__verification', {}, { active: true })}
/>
)}
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__time">
{o.time}
<FormatTime time={o.time} />
</a>
{!!props.level && props.level > 0 && props.view === 'main' && (
<a
className="comment__link-to-parent"
href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.pid}`}
aria-label="Go to parent comment"
title="Go to parent comment"
aria-label={goToParentMessage}
title={goToParentMessage}
onClick={e => this.scrollToParent(e)}
>
{' '}
</a>
)}
{props.isUserBanned && props.view !== 'user' && <span className="comment__status">Blocked</span>}
{props.isUserBanned && props.view !== 'user' && (
<span className="comment__status">
<FormattedMessage id="comment.blocked-user" defaultMessage="Blocked" />
</span>
)}
{isAdmin && !props.isUserBanned && props.data.delete && <span className="comment__status">Deleted</span>}
{isAdmin && !props.isUserBanned && props.data.delete && (
<span className="comment__status">
<FormattedMessage id="comment.deleted-user" defaultMessage="Deleted" />
</span>
)}
<span className={b('comment__score', {}, { view: o.score.view })}>
<span
@@ -660,7 +768,11 @@ export class Comment extends Component<Props, State> {
<div className="comment__body">
{!!state.voteErrorMessage && (
<div className="voting__error" role="alert">
Voting error: {state.voteErrorMessage}
<FormattedMessage
id="comment.vote-error"
defaultMessage="Voting error: {voteErrorMessage}"
values={{ voteErrorMessage: state.voteErrorMessage }}
/>
</div>
)}
@@ -676,7 +788,11 @@ export class Comment extends Component<Props, State> {
<div className="comment__actions">
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && (
<Button kind="link" {...getHandleClickProps(this.toggleReplying)} mix="comment__action">
{isReplying ? 'Cancel' : 'Reply'}
{isReplying ? (
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
) : (
<FormattedMessage id="comment.reply" defaultMessage="Reply" />
)}
</Button>
)}
{!props.data.delete &&
@@ -690,7 +806,11 @@ export class Comment extends Component<Props, State> {
{...getHandleClickProps(this.toggleEditing)}
mix={['comment__action', 'comment__action_type_edit']}
>
{isEditing ? 'Cancel' : 'Edit'}
{isEditing ? (
<FormattedMessage id="comment.cancel" defaultMessage="Cancel" />
) : (
<FormattedMessage id="comment.edit" defaultMessage="Edit" />
)}
</Button>,
!isAdmin && (
<Button
@@ -698,7 +818,7 @@ export class Comment extends Component<Props, State> {
{...getHandleClickProps(this.deleteComment)}
mix={['comment__action', 'comment__action_type_delete']}
>
Delete
<FormattedMessage id="comment.delete" defaultMessage="Delete" />
</Button>
),
state.editDeadline && (
@@ -721,6 +841,7 @@ export class Comment extends Component<Props, State> {
{CommentForm && isReplying && props.view === 'main' && (
<CommentForm
intl={this.props.intl}
user={props.user}
theme={props.theme}
value=""
@@ -737,6 +858,7 @@ export class Comment extends Component<Props, State> {
{CommentForm && isEditing && props.view === 'main' && (
<CommentForm
intl={this.props.intl}
user={props.user}
theme={props.theme}
value={o.orig}
@@ -745,7 +867,7 @@ export class Comment extends Component<Props, State> {
onSubmit={(text, _title) => this.updateComment(props.data.id, text)}
onCancel={this.toggleEditing}
getPreview={this.props.getPreview!}
errorMessage={state.editDeadline === null ? 'Editing time has expired.' : undefined}
errorMessage={state.editDeadline === null ? intl.formatMessage(messages.expiredTime) : undefined}
autofocus={true}
uploadImage={uploadImageHandler}
simpleView={StaticStore.config.simple_view}
@@ -767,13 +889,23 @@ function getTextSnippet(html: string) {
return snippet.length === LENGTH && result.length !== LENGTH ? `${snippet}...` : snippet;
}
function formatTime(time: Date) {
// 'ru-RU' adds a dot as a separator
const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' });
// do it manually because Intl API doesn't add leading zeros to hours; idk why
const hours = `0${time.getHours()}`.slice(-2);
const mins = `0${time.getMinutes()}`.slice(-2);
return `${date} at ${hours}:${mins}`;
function FormatTime({ time }: { time: Date }) {
const intl = useIntl();
return (
<FormattedMessage
id="comment.time"
defaultMessage="{day} at {time}"
values={{
day: intl.formatDate(time),
time: intl.formatTime(time),
}}
/>
);
}
const CommentWithIntl = (props: PropsWithoutIntl) => {
const intl = useIntl();
return <Comment intl={intl} {...props} />;
};
export { CommentWithIntl as Comment };
@@ -0,0 +1,42 @@
import { BlockingDuration } from '@app/common/types';
import { IntlShape, defineMessages } from 'react-intl';
const blockingMessages = defineMessages({
permanently: {
id: 'blockingDuration.permanently',
defaultMessage: 'Permanently',
},
month: {
id: 'blockingDuration.month',
defaultMessage: 'For a month',
},
week: {
id: 'blockingDuration.week',
defaultMessage: 'For a week',
},
day: {
id: 'blockingDuration.day',
defaultMessage: 'For a day',
},
});
export function getBlockingDurations(intl: IntlShape): BlockingDuration[] {
return [
{
label: intl.formatMessage(blockingMessages.permanently),
value: 'permanently',
},
{
label: intl.formatMessage(blockingMessages.month),
value: '43200m',
},
{
label: intl.formatMessage(blockingMessages.week),
value: '10080m',
},
{
label: intl.formatMessage(blockingMessages.day),
value: '1440m',
},
];
}
@@ -0,0 +1,55 @@
import { IntlShape, defineMessages } from 'react-intl';
const voteMessages = defineMessages({
ownComment: {
id: 'vote.own-comment',
defaultMessage: `Can't vote for your own comment`,
},
guest: {
id: 'vote.guest',
defaultMessage: 'Sign in to vote',
},
onlyPostPage: {
id: 'vote.only-post-page',
defaultMessage: `Voting allowed only on post's page`,
},
readonly: {
id: 'vote.readonly',
defaultMessage: `Can't vote on read-only topics`,
},
deleted: {
id: 'vote.deleted',
defaultMessage: `Can't vote for deleted comment`,
},
anonymous: {
id: 'vote.anonymous',
defaultMessage: `Anonymous users can't vote`,
},
onlyPositive: {
id: 'vote.only-positive',
defaultMessage: `Only positive score allowed`,
},
});
export enum VoteMessagesTypes {
OWN_COMMENT,
GUEST,
ONLY_POST_PAGE,
READONLY,
DELETED,
ANONYMOUS,
ONLY_POSITIVE,
}
export function getVoteMessage(type: VoteMessagesTypes, intl: IntlShape) {
const messages = {
[VoteMessagesTypes.OWN_COMMENT]: intl.formatMessage(voteMessages.ownComment),
[VoteMessagesTypes.GUEST]: intl.formatMessage(voteMessages.guest),
[VoteMessagesTypes.ONLY_POST_PAGE]: intl.formatMessage(voteMessages.onlyPostPage),
[VoteMessagesTypes.READONLY]: intl.formatMessage(voteMessages.readonly),
[VoteMessagesTypes.DELETED]: intl.formatMessage(voteMessages.deleted),
[VoteMessagesTypes.ANONYMOUS]: intl.formatMessage(voteMessages.anonymous),
[VoteMessagesTypes.ONLY_POSITIVE]: intl.formatMessage(voteMessages.onlyPositive),
};
return messages[type];
}
+31 -8
View File
@@ -2,6 +2,7 @@
import { createElement, Component, FunctionComponent } from 'preact';
import { useSelector } from 'react-redux';
import b from 'bem-react-helper';
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { User, Sorting, AuthProvider } from '@app/common/types';
import {
@@ -79,7 +80,7 @@ const boundActions = bindActions({
updateComment,
});
type Props = ReturnType<typeof mapStateToProps> & typeof boundActions;
type Props = ReturnType<typeof mapStateToProps> & typeof boundActions & { intl: IntlShape };
interface State {
isLoaded: boolean;
@@ -88,6 +89,13 @@ interface State {
wasSomeoneUnblocked: boolean;
}
const messages = defineMessages({
pinnedComments: {
id: `root.pinned-comments`,
defaultMessage: 'Pinned comments',
},
});
/** main component fr main comments widget */
export class Root extends Component<Props, State> {
constructor(props: Props) {
@@ -243,6 +251,7 @@ export class Root extends Component<Props, State> {
<div className="root__main">
{!isGuest && !isCommentsDisabled && (
<CommentForm
intl={this.props.intl}
theme={props.theme}
mix="root__input"
mode="main"
@@ -255,10 +264,15 @@ export class Root extends Component<Props, State> {
)}
{this.props.pinnedComments.length > 0 && (
<div className="root__pinned-comments" role="region" aria-label="Pinned comments">
<div
className="root__pinned-comments"
role="region"
aria-label={this.props.intl.formatMessage(messages.pinnedComments)}
>
{this.props.pinnedComments.map(comment => (
<Comment
CommentForm={CommentForm}
intl={this.props.intl}
key={`pinned-comment-${comment.id}`}
view="pinned"
data={comment}
@@ -287,7 +301,7 @@ export class Root extends Component<Props, State> {
{commentsShown < this.props.topComments.length && IS_MOBILE && (
<Button kind="primary" size="middle" mix="root__show-more" onClick={this.showMore}>
Show more
<FormattedMessage id="root.show-more" defaultMessage="Show more" />
</Button>
)}
</div>
@@ -304,6 +318,7 @@ export class Root extends Component<Props, State> {
{this.props.isSettingsVisible && (
<div className="root__main">
<Settings
intl={this.props.intl}
user={this.props.user}
hiddenUsers={this.props.hiddenUsers}
blockedUsers={this.props.blockedUsers}
@@ -317,10 +332,17 @@ export class Root extends Component<Props, State> {
)}
<p className="root__copyright" role="contentinfo">
Powered by{' '}
<a href="https://remark42.com/" className="root__copyright-link">
Remark42
</a>
<FormattedMessage
id="root.powered-by"
defaultMessage="Powered by <a>Remark42</a>"
values={{
a: (title: string) => (
<a class="root__copyright-link" href="https://remark42.com/">
{title}
</a>
),
}}
/>
</p>
</div>
</div>
@@ -332,5 +354,6 @@ export class Root extends Component<Props, State> {
export const ConnectedRoot: FunctionComponent = () => {
const props = useSelector(mapStateToProps);
const actions = useActions(boundActions);
return <Root {...props} {...actions} />;
const intl = useIntl();
return <Root {...props} {...actions} intl={intl} />;
};
+74 -25
View File
@@ -5,9 +5,11 @@ import b from 'bem-react-helper';
import { User, BlockedUser, Theme, BlockTTL } from '@app/common/types';
import { getHandleClickProps } from '@app/common/accessibility';
import { StoreState } from '@app/store';
import { defineMessages, IntlShape, FormattedMessage, useIntl } from 'react-intl';
interface Props {
theme: Theme;
intl: IntlShape;
user: StoreState['user'];
blockedUsers: BlockedUser[];
hiddenUsers: StoreState['hiddenUsers'];
@@ -29,6 +31,25 @@ interface State {
unhiddenUsers: User['id'][];
}
const messages = defineMessages({
blockUser: {
id: 'settings.block-user',
defaultMessage: 'Do you want to block {userName}?',
},
unblockUser: {
id: 'settings.unblock-user',
defaultMessage: 'Do you want to unblock {userName}?',
},
hiddenUsers: {
id: 'settings.hidden-users-title',
defaultMessage: 'Hidden users',
},
blockedUsers: {
id: 'settings.blocked-users-title',
defaultMessage: 'Blocked users',
},
});
export default class Settings extends Component<Props, State> {
constructor(props: Props) {
super(props);
@@ -42,7 +63,7 @@ export default class Settings extends Component<Props, State> {
}
block = (user: BlockedUser) => {
if (!confirm(`Do you want to block ${user.name}?`)) return;
if (!confirm(this.props.intl.formatMessage(messages.blockUser, { userName: user.name }))) return;
this.setState({
unblockedUsers: this.state.unblockedUsers.filter(x => x !== user.id),
});
@@ -50,7 +71,7 @@ export default class Settings extends Component<Props, State> {
};
unblock = (user: BlockedUser) => {
if (!confirm(`Do you want to unblock ${user.name}?`)) return;
if (!confirm(this.props.intl.formatMessage(messages.unblockUser, { userName: user.name }))) return;
this.setState({ unblockedUsers: this.state.unblockedUsers.concat([user.id]) });
this.props.unblockUser(user.id);
this.props.onUnblockSomeone();
@@ -76,11 +97,22 @@ export default class Settings extends Component<Props, State> {
render({ user, theme }: Props, { blockedUsers, unblockedUsers, unhiddenUsers }: State) {
const hiddenUsersList = Object.values(this.state.hiddenUsers);
const intl = this.props.intl;
return (
<div className={b('settings', {}, { theme })}>
<div className="settings__section settings__hidden-users" role="region" aria-label="Hidden users">
<h3>Hidden users:</h3>
{!hiddenUsersList.length && <h4 className="settings__dimmed">There are no hidden users.</h4>}
<div
className="settings__section settings__hidden-users"
role="region"
aria-label={intl.formatMessage(messages.hiddenUsers)}
>
<h3>
<FormattedMessage id="settings.hidden-user-header" defaultMessage="Hidden users:" />
</h3>
{!hiddenUsersList.length && (
<h4 className="settings__dimmed">
<FormattedMessage id="settings.no-hidden-users" defaultMessage="There are no hidden users." />
</h4>
)}
{!!hiddenUsersList.length && (
<ul className="settings__list">
{hiddenUsersList.map(user => {
@@ -92,15 +124,15 @@ export default class Settings extends Component<Props, State> {
className={['settings__username', isUserUnhidden ? 'settings__invisible' : null].join(' ')}
title={user.id}
>
{user.name || 'unknown'}
{user.name ? user.name : <FormattedMessage id="settings.unknown" defaultMessage="unknown" />}
</span>
{this.__isUserHidden(user) ? (
<span className="settings__action" {...getHandleClickProps(() => this.unhide(user))}>
show
<FormattedMessage id="settings.show" defaultMessage="show" />
</span>
) : (
<span className="settings__action" {...getHandleClickProps(() => this.hide(user))}>
hide
<FormattedMessage id="settings.hide" defaultMessage="hide" />
</span>
)}
<div>
@@ -115,10 +147,20 @@ export default class Settings extends Component<Props, State> {
)}
</div>
{user && user.admin && (
<div className="settings__section settings__blocked-users" role="region" aria-label="Blocked users">
<h3>Blocked users:</h3>
<div
className="settings__section settings__blocked-users"
role="region"
aria-label={intl.formatMessage(messages.blockedUsers)}
>
<h3>
<FormattedMessage id="settings.blocked-users-header" defaultMessage="Blocked users:" />
</h3>
{!blockedUsers.length && <h4 className="settings__dimmed">There are no blocked users.</h4>}
{!blockedUsers.length && (
<h4 className="settings__dimmed">
<FormattedMessage id="settings.no-blocked-users" defaultMessage="There are no blocked users." />
</h4>
)}
{!!blockedUsers.length && (
<ul className="settings__list settings__blocked-users-list">
@@ -131,17 +173,20 @@ export default class Settings extends Component<Props, State> {
className={['settings__username', isUserUnblocked ? 'settings__invisible' : null].join(' ')}
title={user.id}
>
{user.name || 'unknown'}
{user.name ? user.name : <FormattedMessage id="settings.unknown" defaultMessage="unknown" />}
</span>
<span className="settings__blocked-users-user-block-ttl">
{' '}
<FormatTime time={new Date(user.time)} />
</span>
<span className="settings__blocked-users-user-block-ttl"> {formatTime(new Date(user.time))}</span>
{isUserUnblocked && (
<span {...getHandleClickProps(() => this.block(user))} className="settings__action">
block
<FormattedMessage id="settings.block" defaultMessage="block" />
</span>
)}
{!isUserUnblocked && (
<span {...getHandleClickProps(() => this.unblock(user))} className="settings__action">
unblock
<FormattedMessage id="settings.unblock" defaultMessage="unblock" />
</span>
)}
<div>
@@ -163,16 +208,20 @@ export default class Settings extends Component<Props, State> {
const currentYear = new Date().getFullYear();
function formatTime(time: Date): string {
function FormatTime({ time }: { time: Date }) {
const intl = useIntl();
// let's assume that if block ttl is more than 50 years then user blocked permanently
if (time.getFullYear() - currentYear >= 50) return 'permanently';
if (time.getFullYear() - currentYear >= 50)
return <FormattedMessage id="settings.permanently" defaultMessage="permanently" />;
// 'ru-RU' adds a dot as a separator
const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' });
// do it manually because Intl API doesn't add leading zeros to hours; idk why
const hours = `0${time.getHours()}`.slice(-2);
const mins = `0${time.getMinutes()}`.slice(-2);
return `until ${date} at ${hours}:${mins}`;
return (
<FormattedMessage
id="settings.block-time"
defaultMessage="until {day} at {time}"
values={{
day: intl.formatDate(time),
time: intl.formatTime(time),
}}
/>
);
}
@@ -12,6 +12,7 @@ import { getThreadIsCollapsed } from '@app/store/thread/getters';
import { InView } from '@app/components/root/in-view/in-view';
import { ConnectedComment as Comment } from '@app/components/comment/connected-comment';
import { CommentForm } from '@app/components/comment-form';
import { useIntl } from 'react-intl';
interface Props {
id: CommentInterface['id'];
@@ -33,6 +34,7 @@ const commentSelector = (id: string) => (state: StoreState) => {
export const Thread: FunctionComponent<Props> = ({ id, level, mix, getPreview }) => {
const dispatch = useDispatch();
const intl = useIntl();
const { collapsed, comment, childs, theme } = useSelector(commentSelector(id), shallowEqual);
const collapse = useCallback(() => {
dispatch(setCollapse(id, !collapsed));
@@ -56,6 +58,7 @@ export const Thread: FunctionComponent<Props> = ({ id, level, mix, getPreview })
ref={ref => inviewProps.ref(ref)}
key={`comment-${id}`}
view="main"
intl={intl}
data={comment}
repliesCount={repliesCount}
level={level}
@@ -10,7 +10,6 @@ const LastCommentsList = ({ comments, isLoading }: { comments: CommentType[]; is
if (isLoading) {
return <Preloader mix="user-info__preloader" />;
}
return (
<div>
{comments.map(comment => (
@@ -13,12 +13,20 @@ import { AvatarIcon } from '../avatar-icon';
import postMessage from '@app/utils/postMessage';
import { bindActions } from '@app/utils/actionBinder';
import { useActions } from '@app/hooks/useAction';
import { useIntl, defineMessages, FormattedMessage, IntlShape } from 'react-intl';
const boundActions = bindActions({ fetchInfo });
const messages = defineMessages({
unexpectedError: {
id: 'user-info.unexpected-error',
defaultMessage: 'Something went wrong',
},
});
type Props = {
comments: Comment[] | null;
} & typeof boundActions;
} & typeof boundActions & { intl: IntlShape };
interface State {
isLoading: boolean;
@@ -36,7 +44,7 @@ class UserInfo extends Component<Props, State> {
this.setState({ isLoading: false });
})
.catch(() => {
this.setState({ isLoading: false, error: 'Something went wrong' });
this.setState({ isLoading: false, error: this.props.intl.formatMessage(messages.unexpectedError) });
});
}
@@ -60,7 +68,13 @@ class UserInfo extends Component<Props, State> {
return (
<div className={b('user-info', {})}>
<AvatarIcon mix="user-info__avatar" picture={user.picture} />
<p className="user-info__title">Last comments by {user.name}</p>
<p className="user-info__title">
<FormattedMessage
id="user-info.last-comments"
defaultMessage="Last comments by {userName}"
values={{ userName: user.name }}
/>
</p>
<p className="user-info__id">{user.id}</p>
{!!comments && <LastCommentsList isLoading={isLoading} comments={comments} />}
@@ -85,5 +99,6 @@ const commentsSelector = (state: StoreState) => state.userComments![userInfo.id!
export const ConnectedUserInfo: FunctionComponent = () => {
const comments = useSelector(commentsSelector);
const actions = useActions(boundActions);
return <UserInfo comments={comments} {...actions} />;
const intl = useIntl();
return <UserInfo comments={comments} {...actions} intl={intl} />;
};
+11 -2
View File
@@ -14,6 +14,9 @@ import { getLastComments } from './common/api';
import { LastCommentsConfig } from '@app/common/config-types';
import { BASE_URL, DEFAULT_LAST_COMMENTS_MAX, LAST_COMMENTS_NODE_CLASSNAME } from '@app/common/constants';
import { ListComments } from '@app/components/list-comments';
import { IntlProvider } from 'react-intl';
import { loadLocale } from './utils/loadLocale';
import { getLocale } from './utils/getLocale';
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
@@ -55,9 +58,15 @@ async function init(): Promise<void> {
(node.dataset.max && parseInt(node.dataset.max, 10)) ||
remark_config.max_last_comments ||
DEFAULT_LAST_COMMENTS_MAX;
getLastComments(remark_config.site_id!, max).then(comments => {
const locale = getLocale(remark_config);
Promise.all([getLastComments(remark_config.site_id!, max), loadLocale(locale)]).then(([comments, messages]) => {
try {
render(<ListComments comments={comments} />, node);
render(
<IntlProvider locale={locale} messages={messages}>
<ListComments comments={comments} />
</IntlProvider>,
node
);
} catch (e) {
console.error('Remark42: Something went wrong with last comments rendering');
console.error(e);
+167
View File
@@ -0,0 +1,167 @@
{
"anonymousLoginForm.length-limit": "Username must be at least 3 characters long",
"anonymousLoginForm.log-in": "Log in",
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
"anonymousLoginForm.user-name": "Username",
"authPanel.anonymous-provider": "Anonymous",
"authPanel.disable-comments": "Disable comments",
"authPanel.disabled-cookies": "Disable third-party cookies blocking to login or open comments in",
"authPanel.enable-comments": "Enable comments",
"authPanel.enable-cookies": "Allow cookies to login and comment",
"authPanel.hide-settings": "Hide settings",
"authPanel.logged-as": "You logged in as",
"authPanel.login": "Login:",
"authPanel.logout": "Logout?",
"authPanel.or-provider": "or",
"authPanel.other-provider": "Other",
"authPanel.read-only": "Read-only",
"authPanel.request-to-delete-data": "Request my data removal",
"authPanel.show-settings": "Show settings",
"blockingDuration.day": "For a day",
"blockingDuration.month": "For a month",
"blockingDuration.permanently": "Permanently",
"blockingDuration.week": "For a week",
"comment.block": "Block",
"comment.block-user": "Do you want to block {userName} {duration}?",
"comment.blocked-user": "Blocked",
"comment.blocking-period": "Blocking period",
"comment.cancel": "Cancel",
"comment.controversy": "Controversy: {value}",
"comment.copied": "Copied!",
"comment.copy": "Copy",
"comment.delete": "Delete",
"comment.delete-message": "Do you want to delete this comment?",
"comment.deleted-comment": "This comment was deleted",
"comment.deleted-user": "Deleted",
"comment.edit": "Edit",
"comment.expired-time": "Editing time has expired.",
"comment.go-to-parent": "Go to parent comment",
"comment.hide": "Hide",
"comment.hide-user-comment": "Do you want to hide comments of {userName}?",
"comment.pin": "Pin",
"comment.pin-comment": "Do you want to pin this comment?",
"comment.reply": "Reply",
"comment.time": "{day} at {time}",
"comment.toggle-verification": "Toggle verification",
"comment.unblock": "Unblock",
"comment.unblock-user": "Do you want to unblock this user?",
"comment.unpin": "Unpin",
"comment.unpin-comment": "Do you want to unpin this comment?",
"comment.unverified-user": "Unverified user",
"comment.unverify-user": "Do you want to unverify {userName}?",
"comment.verified-user": "Verified user",
"comment.verify-user": "Do you want to verify {userName}?",
"comment.vote-error": "Voting error: {voteErrorMessage}",
"commentForm.exceeded-size": "{fileName} exceeds size limit of {maxImageSize}",
"commentForm.input-placeholder": "Your comment here",
"commentForm.new-comment": "New comment",
"commentForm.notice-about-styling": "Styling with <a>Markdown</a> is supported",
"commentForm.preview": "Preview",
"commentForm.reply": "Reply",
"commentForm.save": "Save",
"commentForm.send": "Send",
"commentForm.subscribe-by": "Subscribe by",
"commentForm.subscribe-or": "or",
"commentForm.unexpected-error": "Something went wrong. Please try again a bit later.",
"commentForm.upload-file-fail": "{fileName} upload failed with \"{errorMessage}\"",
"commentForm.uploading": "Uploading...",
"commentForm.uploading-file": "uploading {fileName}...",
"commentSort.sort-by": "Sort by",
"commentsSort.best": "Best",
"commentsSort.least-controversial": "Least controversial",
"commentsSort.least-recently-updated": "Least recently updated",
"commentsSort.most-controversial": "Most controversial",
"commentsSort.newest": "Newest",
"commentsSort.oldest": "Oldest",
"commentsSort.recently-updated": "Recently updated",
"commentsSort.worst": "Worst",
"emailLoginForm.back": "Back",
"emailLoginForm.confirm": "Confirm",
"emailLoginForm.email-address": "Email Address",
"emailLoginForm.empty-token": "Token field must not be empty",
"emailLoginForm.expired-token": "Token is expired",
"emailLoginForm.invalid-email": "Address should be valid email address",
"emailLoginForm.loading": "Loading...",
"emailLoginForm.send-verification": "Send Verification",
"emailLoginForm.token": "Token",
"emailLoginForm.user-not-found": "No user was found",
"errors.0": "Something went wrong. Please try again a bit later.",
"errors.1": "Comment cannot be found. Please refresh the page and try again.",
"errors.10": "It is too late to edit the comment.",
"errors.11": "Comment already has reply, editing is not possible.",
"errors.12": "Cannot save voting result. Please try again a bit later.",
"errors.13": "You cannot vote for your own comment.",
"errors.14": "You have already voted for the comment.",
"errors.15": "Too many votes for the comment.",
"errors.16": "Min score reached for the comment.",
"errors.17": "Action rejected. Please try again a bit later.",
"errors.18": "Requested file cannot be found.",
"errors.2": "Failed to unmarshal incoming request.",
"errors.3": "You don't have permission for this operation.",
"errors.4": "Invalid comment data.",
"errors.5": "Comment cannot be found. Please refresh the page and try again.",
"errors.6": "Site cannot be found. Please refresh the page and try again.",
"errors.7": "User has been blocked.",
"errors.8": "User has been blocked.",
"errors.9": "Comment changing failed. Please try again a bit later.",
"errors.failed-fetch": "Failed to fetch. Please check your internet connection or try again a bit later",
"errors.forbidden": "Forbidden.",
"errors.not-authorized": "Not authorized.",
"errors.to-many-request": "You have reached maximum request limit.",
"errors.unexpected-error": "Something went wrong.",
"root.pinned-comments": "Pinned comments",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Show more",
"settings.block": "block",
"settings.block-time": "until {day} at {time}",
"settings.block-user": "Do you want to block {userName}?",
"settings.blocked-users-header": "Blocked users:",
"settings.blocked-users-title": "Blocked users",
"settings.hidden-user-header": "Hidden users:",
"settings.hidden-users-title": "Hidden users",
"settings.hide": "hide",
"settings.no-blocked-users": "There are no blocked users.",
"settings.no-hidden-users": "There are no hidden users.",
"settings.permanently": "permanently",
"settings.show": "show",
"settings.unblock": "unblock",
"settings.unblock-user": "Do you want to unblock {userName}?",
"settings.unknown": "unknown",
"subscribeByEmail.back": "Back",
"subscribeByEmail.close": "Close",
"subscribeByEmail.email": "Email",
"subscribeByEmail.expired-token": "Expired token",
"subscribeByEmail.have-been-subscribed": "You have been subscribed on updates by email",
"subscribeByEmail.have-been-unsubscribed": "You have been unsubscribed by email to updates",
"subscribeByEmail.only-registered-users": "Available only for registered users",
"subscribeByEmail.submit": "Submit",
"subscribeByEmail.subscribe": "Subscribe",
"subscribeByEmail.subscribe-by-email": "Subscribe by Email",
"subscribeByEmail.subscribe-to-replies": "Subscribe to replies",
"subscribeByEmail.subscribed": "You are subscribed on updates by email",
"subscribeByEmail.token": "Token",
"subscribeByEmail.unsubscribe": "Unsubscribe",
"subscribeByRSS.button-title": "Subscribe by RSS",
"subscribeByRSS.replies": "Replies",
"subscribeByRSS.site": "Site",
"subscribeByRSS.thread": "Thread",
"subscribeByRSS.title": "RSS",
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
"toolbar.bold": "Add bold text <cmd-b>",
"toolbar.code": "Insert a code",
"toolbar.header": "Add header text",
"toolbar.italic": "Add italic text <cmd-i>",
"toolbar.link": "Add a link <cmd-k>",
"toolbar.ordered-list": "Add a numbered list",
"toolbar.quote": "Insert a quote",
"toolbar.unordered-list": "Add a bulleted list",
"user-info.last-comments": "Last comments by {userName}",
"user-info.unexpected-error": "Something went wrong",
"vote.anonymous": "Anonymous users can't vote",
"vote.deleted": "Can't vote for deleted comment",
"vote.guest": "Sign in to vote",
"vote.only-positive": "Only positive score allowed",
"vote.only-post-page": "Voting allowed only on post's page",
"vote.own-comment": "Can't vote for your own comment",
"vote.readonly": "Can't vote on read-only topics"
}
+167
View File
@@ -0,0 +1,167 @@
{
"anonymousLoginForm.length-limit": "Username must be at least 3 characters long",
"anonymousLoginForm.log-in": "Log in",
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
"anonymousLoginForm.user-name": "Username",
"authPanel.anonymous-provider": "Anonymous",
"authPanel.disable-comments": "Disable comments",
"authPanel.disabled-cookies": "Disable third-party cookies blocking to login or open comments in",
"authPanel.enable-comments": "Enable comments",
"authPanel.enable-cookies": "Allow cookies to login and comment",
"authPanel.hide-settings": "Hide settings",
"authPanel.logged-as": "You logged in as",
"authPanel.login": "Login:",
"authPanel.logout": "Logout?",
"authPanel.or-provider": "or",
"authPanel.other-provider": "Other",
"authPanel.read-only": "Read-only",
"authPanel.request-to-delete-data": "Request my data removal",
"authPanel.show-settings": "Show settings",
"blockingDuration.day": "For a day",
"blockingDuration.month": "For a month",
"blockingDuration.permanently": "Permanently",
"blockingDuration.week": "For a week",
"comment.block": "Block",
"comment.block-user": "Do you want to block {userName} {duration}?",
"comment.blocked-user": "Blocked",
"comment.blocking-period": "Blocking period",
"comment.cancel": "Cancel",
"comment.controversy": "Controversy: {value}",
"comment.copied": "Copied!",
"comment.copy": "Copy",
"comment.delete": "Delete",
"comment.delete-message": "Do you want to delete this comment?",
"comment.deleted-comment": "This comment was deleted",
"comment.deleted-user": "Deleted",
"comment.edit": "Edit",
"comment.expired-time": "Editing time has expired.",
"comment.go-to-parent": "Go to parent comment",
"comment.hide": "Hide",
"comment.hide-user-comment": "Do you want to hide comments of {userName}?",
"comment.pin": "Pin",
"comment.pin-comment": "Do you want to pin this comment?",
"comment.reply": "Reply",
"comment.time": "{day} at {time}",
"comment.toggle-verification": "Toggle verification",
"comment.unblock": "Unblock",
"comment.unblock-user": "Do you want to unblock this user?",
"comment.unpin": "Unpin",
"comment.unpin-comment": "Do you want to unpin this comment?",
"comment.unverified-user": "Unverified user",
"comment.unverify-user": "Do you want to unverify {userName}?",
"comment.verified-user": "Verified user",
"comment.verify-user": "Do you want to verify {userName}?",
"comment.vote-error": "Voting error: {voteErrorMessage}",
"commentForm.exceeded-size": "{fileName} exceeds size limit of {maxImageSize}",
"commentForm.input-placeholder": "Your comment here",
"commentForm.new-comment": "New comment",
"commentForm.notice-about-styling": "Styling with <a>Markdown</a> is supported",
"commentForm.preview": "Preview",
"commentForm.reply": "Reply",
"commentForm.save": "Save",
"commentForm.send": "Send",
"commentForm.subscribe-by": "Subscribe by",
"commentForm.subscribe-or": "or",
"commentForm.unexpected-error": "Something went wrong. Please try again a bit later.",
"commentForm.upload-file-fail": "{fileName} upload failed with \"{errorMessage}\"",
"commentForm.uploading": "Uploading...",
"commentForm.uploading-file": "uploading {fileName}...",
"commentSort.sort-by": "Sort by",
"commentsSort.best": "Best",
"commentsSort.least-controversial": "Least controversial",
"commentsSort.least-recently-updated": "Least recently updated",
"commentsSort.most-controversial": "Most controversial",
"commentsSort.newest": "Newest",
"commentsSort.oldest": "Oldest",
"commentsSort.recently-updated": "Recently updated",
"commentsSort.worst": "Worst",
"emailLoginForm.back": "Back",
"emailLoginForm.confirm": "Confirm",
"emailLoginForm.email-address": "Email Address",
"emailLoginForm.empty-token": "Token field must not be empty",
"emailLoginForm.expired-token": "Token is expired",
"emailLoginForm.invalid-email": "Address should be valid email address",
"emailLoginForm.loading": "Loading...",
"emailLoginForm.send-verification": "Send Verification",
"emailLoginForm.token": "Token",
"emailLoginForm.user-not-found": "No user was found",
"errors.0": "Something went wrong. Please try again a bit later.",
"errors.1": "Comment cannot be found. Please refresh the page and try again.",
"errors.10": "It is too late to edit the comment.",
"errors.11": "Comment already has reply, editing is not possible.",
"errors.12": "Cannot save voting result. Please try again a bit later.",
"errors.13": "You cannot vote for your own comment.",
"errors.14": "You have already voted for the comment.",
"errors.15": "Too many votes for the comment.",
"errors.16": "Min score reached for the comment.",
"errors.17": "Action rejected. Please try again a bit later.",
"errors.18": "Requested file cannot be found.",
"errors.2": "Failed to unmarshal incoming request.",
"errors.3": "You don't have permission for this operation.",
"errors.4": "Invalid comment data.",
"errors.5": "Comment cannot be found. Please refresh the page and try again.",
"errors.6": "Site cannot be found. Please refresh the page and try again.",
"errors.7": "User has been blocked.",
"errors.8": "User has been blocked.",
"errors.9": "Comment changing failed. Please try again a bit later.",
"errors.failed-fetch": "Failed to fetch. Please check your internet connection or try again a bit later",
"errors.forbidden": "Forbidden.",
"errors.not-authorized": "Not authorized.",
"errors.to-many-request": "You have reached maximum request limit.",
"errors.unexpected-error": "Something went wrong.",
"root.pinned-comments": "Pinned comments",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Show more",
"settings.block": "block",
"settings.block-time": "until {day} at {time}",
"settings.block-user": "Do you want to block {userName}?",
"settings.blocked-users-header": "Blocked users:",
"settings.blocked-users-title": "Blocked users",
"settings.hidden-user-header": "Hidden users:",
"settings.hidden-users-title": "Hidden users",
"settings.hide": "hide",
"settings.no-blocked-users": "There are no blocked users.",
"settings.no-hidden-users": "There are no hidden users.",
"settings.permanently": "permanently",
"settings.show": "show",
"settings.unblock": "unblock",
"settings.unblock-user": "Do you want to unblock {userName}?",
"settings.unknown": "unknown",
"subscribeByEmail.back": "Back",
"subscribeByEmail.close": "Close",
"subscribeByEmail.email": "Email",
"subscribeByEmail.expired-token": "Expired token",
"subscribeByEmail.have-been-subscribed": "You have been subscribed on updates by email",
"subscribeByEmail.have-been-unsubscribed": "You have been unsubscribed by email to updates",
"subscribeByEmail.only-registered-users": "Available only for registered users",
"subscribeByEmail.submit": "Submit",
"subscribeByEmail.subscribe": "Subscribe",
"subscribeByEmail.subscribe-by-email": "Subscribe by Email",
"subscribeByEmail.subscribe-to-replies": "Subscribe to replies",
"subscribeByEmail.subscribed": "You are subscribed on updates by email",
"subscribeByEmail.token": "Token",
"subscribeByEmail.unsubscribe": "Unsubscribe",
"subscribeByRSS.button-title": "Subscribe by RSS",
"subscribeByRSS.replies": "Replies",
"subscribeByRSS.site": "Site",
"subscribeByRSS.thread": "Thread",
"subscribeByRSS.title": "RSS",
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
"toolbar.bold": "Add bold text <cmd-b>",
"toolbar.code": "Insert a code",
"toolbar.header": "Add header text",
"toolbar.italic": "Add italic text <cmd-i>",
"toolbar.link": "Add a link <cmd-k>",
"toolbar.ordered-list": "Add a numbered list",
"toolbar.quote": "Insert a quote",
"toolbar.unordered-list": "Add a bulleted list",
"user-info.last-comments": "Last comments by {userName}",
"user-info.unexpected-error": "Something went wrong",
"vote.anonymous": "Anonymous users can't vote",
"vote.deleted": "Can't vote for deleted comment",
"vote.guest": "Sign in to vote",
"vote.only-positive": "Only positive score allowed",
"vote.only-post-page": "Voting allowed only on post's page",
"vote.own-comment": "Can't vote for your own comment",
"vote.readonly": "Can't vote on read-only topics"
}
+167
View File
@@ -0,0 +1,167 @@
{
"anonymousLoginForm.length-limit": "Длина имени должна быть больше 3 символов",
"anonymousLoginForm.log-in": "Войти",
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
"anonymousLoginForm.user-name": "Username",
"authPanel.anonymous-provider": "Анонимно",
"authPanel.disable-comments": "Выключить комментарии",
"authPanel.disabled-cookies": "Disable third-party cookies blocking to login or open comments in",
"authPanel.enable-comments": "Включить комментарии",
"authPanel.enable-cookies": "Allow cookies to login and comment",
"authPanel.hide-settings": "Спрятать настройки",
"authPanel.logged-as": "Вы вошли как",
"authPanel.login": "Вход:",
"authPanel.logout": "Выйти?",
"authPanel.or-provider": "или",
"authPanel.other-provider": "Другой",
"authPanel.read-only": "Только для чтение",
"authPanel.request-to-delete-data": "Запросить удаление моих данных",
"authPanel.show-settings": "Показать настройки",
"blockingDuration.day": "На день",
"blockingDuration.month": "На месяц",
"blockingDuration.permanently": "Навсегда",
"blockingDuration.week": "На неделю",
"comment.block": "Блокировать",
"comment.block-user": "Заблокировать пользователя {userName} {duration}?",
"comment.blocked-user": "Заблокирован",
"comment.blocking-period": "Период блокировки",
"comment.cancel": "Отменить",
"comment.controversy": "Спорность: {value}",
"comment.copied": "Скопировано!",
"comment.copy": "Копировать",
"comment.delete": "Удалить",
"comment.delete-message": "Удалить комментарий?",
"comment.deleted-comment": "Комментарий был удалён",
"comment.deleted-user": "Удалён",
"comment.edit": "Редактировать",
"comment.expired-time": "Время редактирования истекло.",
"comment.go-to-parent": "Go to parent comment",
"comment.hide": "Спрятать",
"comment.hide-user-comment": "Спрятать комментарии от пользователя {userName}?",
"comment.pin": "Закрепить",
"comment.pin-comment": "Закрепить комментарий?",
"comment.reply": "Ответить",
"comment.time": "{day} в {time}",
"comment.toggle-verification": "Изменить статут подлинности учетной записи",
"comment.unblock": "Разблокировать",
"comment.unblock-user": "Разбокировать пользователя?",
"comment.unpin": "Открепить",
"comment.unpin-comment": "Открепить комментарий?",
"comment.unverified-user": " Не проверенная на подлинность учетная запись",
"comment.unverify-user": "Убрать статус подлинности учетной записи для {userName}?",
"comment.verified-user": "Подлинная учетная запись",
"comment.verify-user": "Подвердить подлинность учетной записи для {userName}?",
"comment.vote-error": "Ошибка голосования: {voteErrorMessage}",
"commentForm.exceeded-size": "Размер файла {fileName} должен быть меньше чем {maxImageSize}",
"commentForm.input-placeholder": "Написать комментарий",
"commentForm.new-comment": "New comment",
"commentForm.notice-about-styling": "Поддерживается <a>Markdown</a> форматирование",
"commentForm.preview": "Предпросмотр",
"commentForm.reply": "Ответить",
"commentForm.save": "Сохранить",
"commentForm.send": "Отправить",
"commentForm.subscribe-by": "Подписаться",
"commentForm.subscribe-or": "или",
"commentForm.unexpected-error": "Something went wrong. Please try again a bit later.",
"commentForm.upload-file-fail": "{fileName} невозможно загрузить из-за ошибки: \"{errorMessage}\"",
"commentForm.uploading": "Загрузка...",
"commentForm.uploading-file": "Загрузка {fileName}...",
"commentSort.sort-by": "Сортировать по",
"commentsSort.best": "Лучшие",
"commentsSort.least-controversial": "Наименее спорные",
"commentsSort.least-recently-updated": "Давно обновленные",
"commentsSort.most-controversial": "Наиболее спорные",
"commentsSort.newest": "Новые",
"commentsSort.oldest": "Старые",
"commentsSort.recently-updated": "Недавно обновленные",
"commentsSort.worst": "Худшие",
"emailLoginForm.back": "Back",
"emailLoginForm.confirm": "Confirm",
"emailLoginForm.email-address": "Email Address",
"emailLoginForm.empty-token": "Token field must not be empty",
"emailLoginForm.expired-token": "Token is expired",
"emailLoginForm.invalid-email": "Address should be valid email address",
"emailLoginForm.loading": "Loading...",
"emailLoginForm.send-verification": "Send Verification",
"emailLoginForm.token": "Token",
"emailLoginForm.user-not-found": "No user was found",
"errors.0": "Something went wrong. Please try again a bit later.",
"errors.1": "Comment cannot be found. Please refresh the page and try again.",
"errors.10": "It is too late to edit the comment.",
"errors.11": "Comment already has reply, editing is not possible.",
"errors.12": "Cannot save voting result. Please try again a bit later.",
"errors.13": "You cannot vote for your own comment.",
"errors.14": "Вы уже голосовали за этот комментарий.",
"errors.15": "Too many votes for the comment.",
"errors.16": "Min score reached for the comment.",
"errors.17": "Action rejected. Please try again a bit later.",
"errors.18": "Requested file cannot be found.",
"errors.2": "Failed to unmarshal incoming request.",
"errors.3": "You don't have permission for this operation.",
"errors.4": "Invalid comment data.",
"errors.5": "Comment cannot be found. Please refresh the page and try again.",
"errors.6": "Site cannot be found. Please refresh the page and try again.",
"errors.7": "User has been blocked.",
"errors.8": "User has been blocked.",
"errors.9": "Comment changing failed. Please try again a bit later.",
"errors.failed-fetch": "Нет ответа с сервера. Проверьте ваше соеденение с интернетом или попробуйте позже.",
"errors.forbidden": "Forbidden.",
"errors.not-authorized": "Not authorized.",
"errors.to-many-request": "Слишком много запросов.",
"errors.unexpected-error": "Что-то пошло не так.",
"root.pinned-comments": "Закреплённые комментарии",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Показать ещё",
"settings.block": "блокировать",
"settings.block-time": "до {day} в {time}",
"settings.block-user": "Заблокировать пользователя {userName}?",
"settings.blocked-users-header": "Заблокированные пользователи:",
"settings.blocked-users-title": "Заблокированные пользователи",
"settings.hidden-user-header": "Скрытые пользователи:",
"settings.hidden-users-title": "Скрытые пользователи",
"settings.hide": "Спрятать",
"settings.no-blocked-users": "Нет заблокированных пользователей.",
"settings.no-hidden-users": "Нет скрытых пользователей.",
"settings.permanently": "навсегда",
"settings.show": "Показать",
"settings.unblock": "разблокировать",
"settings.unblock-user": "Разблокировать пользователя {userName}?",
"settings.unknown": "безымянный",
"subscribeByEmail.back": "Back",
"subscribeByEmail.close": "Close",
"subscribeByEmail.email": "Email",
"subscribeByEmail.expired-token": "Expired token",
"subscribeByEmail.have-been-subscribed": "You have been subscribed on updates by email",
"subscribeByEmail.have-been-unsubscribed": "You have been unsubscribed by email to updates",
"subscribeByEmail.only-registered-users": "Available only for registered users",
"subscribeByEmail.submit": "Submit",
"subscribeByEmail.subscribe": "Subscribe",
"subscribeByEmail.subscribe-by-email": "Subscribe by Email",
"subscribeByEmail.subscribe-to-replies": "Subscribe to replies",
"subscribeByEmail.subscribed": "You are subscribed on updates by email",
"subscribeByEmail.token": "Token",
"subscribeByEmail.unsubscribe": "Unsubscribe",
"subscribeByRSS.button-title": "Subscribe by RSS",
"subscribeByRSS.replies": "Replies",
"subscribeByRSS.site": "Site",
"subscribeByRSS.thread": "Thread",
"subscribeByRSS.title": "RSS",
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
"toolbar.bold": "Жирный <cmd-b>",
"toolbar.code": "Код",
"toolbar.header": "Заголовок",
"toolbar.italic": "Курсив <cmd-i>",
"toolbar.link": "Ссылка <cmd-k>",
"toolbar.ordered-list": "Упорядоченный список",
"toolbar.quote": "Цитата",
"toolbar.unordered-list": "Неупорядоченный список",
"user-info.last-comments": "Последние комментарии {userName}",
"user-info.unexpected-error": "Something went wrong",
"vote.anonymous": "Нельзя головать анонимному пользователю",
"vote.deleted": "Нельзя головать за удалённый комментарий",
"vote.guest": "Войдите в систему для голосование",
"vote.only-positive": "Разрешены только положительные оценки",
"vote.only-post-page": "Голосование возожно только для статей",
"vote.own-comment": "Вы не можете голосовать за свои комментарии",
"vote.readonly": "Нельзя головать за комментарии, которые в режиме только для чтения"
}
+19 -10
View File
@@ -7,6 +7,10 @@ if (process.env.NODE_ENV === 'development') {
}
import loadPolyfills from '@app/common/polyfills';
import { IntlProvider } from 'react-intl';
import { loadLocale } from './utils/loadLocale';
import { getLocale } from './utils/getLocale';
import { createElement, render } from 'preact';
import { bindActionCreators } from 'redux';
import { Provider } from 'react-redux';
@@ -61,26 +65,31 @@ async function init(): Promise<void> {
}
return memo;
}, {});
const locale = getLocale(params);
const messages = await loadLocale(locale).catch(() => ({}));
StaticStore.config = await api.getConfig();
if (params.page === 'user-info') {
return render(
<div id={NODE_ID}>
<div className="root root_user-info">
<Provider store={reduxStore}>
<UserInfo />
</Provider>
<IntlProvider locale={locale} messages={messages}>
<div id={NODE_ID}>
<div className="root root_user-info">
<Provider store={reduxStore}>
<UserInfo />
</Provider>
</div>
</div>
</div>,
</IntlProvider>,
node
);
}
render(
<Provider store={reduxStore}>
<ConnectedRoot />
</Provider>,
<IntlProvider locale={locale} messages={messages}>
<Provider store={reduxStore}>
<ConnectedRoot />
</Provider>
</IntlProvider>,
node
);
}
+185 -37
View File
@@ -1,38 +1,190 @@
import { IntlShape, defineMessages, MessageDescriptor } from 'react-intl';
const messages = defineMessages({
failedFetch: {
id: 'errors.failed-fetch',
defaultMessage: 'Failed to fetch. Please check your internet connection or try again a bit later',
description: {
code: -2,
},
},
0: {
id: 'errors.0',
defaultMessage: 'Something went wrong. Please try again a bit later.',
description: {
code: 0,
},
},
1: {
id: 'errors.1',
defaultMessage: 'Comment cannot be found. Please refresh the page and try again.',
description: {
code: 1,
},
},
2: {
id: 'errors.2',
defaultMessage: 'Failed to unmarshal incoming request.',
description: {
code: 2,
},
},
3: {
id: 'errors.3',
defaultMessage: `You don't have permission for this operation.`,
description: {
code: 3,
},
},
4: {
id: 'errors.4',
defaultMessage: `Invalid comment data.`,
description: {
code: 4,
},
},
5: {
id: 'errors.5',
defaultMessage: `Comment cannot be found. Please refresh the page and try again.`,
description: {
code: 5,
},
},
6: {
id: 'errors.6',
defaultMessage: `Site cannot be found. Please refresh the page and try again.`,
description: {
code: 6,
},
},
7: {
id: 'errors.7',
defaultMessage: `User has been blocked.`,
description: {
code: 7,
},
},
8: {
id: 'errors.8',
defaultMessage: `User has been blocked.`,
description: {
code: 8,
},
},
9: {
id: 'errors.9',
defaultMessage: `Comment changing failed. Please try again a bit later.`,
description: {
code: 9,
},
},
10: {
id: 'errors.10',
defaultMessage: `It is too late to edit the comment.`,
description: {
code: 10,
},
},
11: {
id: 'errors.11',
defaultMessage: `Comment already has reply, editing is not possible.`,
description: {
code: 11,
},
},
12: {
id: 'errors.12',
defaultMessage: `Cannot save voting result. Please try again a bit later.`,
description: {
code: 12,
},
},
13: {
id: 'errors.13',
defaultMessage: `You cannot vote for your own comment.`,
description: {
code: 13,
},
},
14: {
id: 'errors.14',
defaultMessage: `You have already voted for the comment.`,
description: {
code: 14,
},
},
15: {
id: 'errors.15',
defaultMessage: `Too many votes for the comment.`,
description: {
code: 15,
},
},
16: {
id: 'errors.16',
defaultMessage: `Min score reached for the comment.`,
description: {
code: 16,
},
},
17: {
id: 'errors.17',
defaultMessage: `Action rejected. Please try again a bit later.`,
description: {
code: 17,
},
},
18: {
id: 'errors.18',
defaultMessage: `Requested file cannot be found.`,
description: {
code: 18,
},
},
});
/**
* map of codes that server returns in its response in case of error
* to client readable version
*/
const errorMessageForCodes = new Map([
[0, 'Something went wrong. Please try again a bit later.'],
[1, 'Comment cannot be found. Please refresh the page and try again.'],
[2, 'Failed to unmarshal incoming request.'],
[3, "You don't have permission for this operaton."],
[4, 'Invalid comment data.'],
[5, 'Comment cannot be found. Please refresh the page and try again.'],
[6, 'Site cannot be found. Please refresh the page and try again.'],
[7, 'User has been blocked.'],
[8, 'This post is read only.'],
[9, 'Comment changing failed. Please try again a bit later.'],
[10, 'It is too late to edit the comment.'],
[11, 'Comment already has reply, editing is not possible.'],
[12, 'Cannot save voting result. Please try again a bit later.'],
[13, 'You cannot vote for your own comment.'],
[14, 'You have already voted for the comment.'],
[15, 'Too many votes for the comment.'],
[16, 'Min score reached for the comment.'],
[17, 'Action rejected. Please try again a bit later.'],
[18, 'Requested file cannot be found.'],
]);
const errorMessageForCodes = new Map<number, MessageDescriptor>();
Object.entries(messages).forEach(([, messageDescriptor]) => {
errorMessageForCodes.set(messageDescriptor.description.code, messageDescriptor);
});
export const httpMessages = defineMessages({
notAuthorized: {
id: 'errors.not-authorized',
defaultMessage: 'Not authorized.',
},
forbidden: {
id: 'errors.forbidden',
defaultMessage: 'Forbidden.',
},
toManyRequest: {
id: 'errors.to-many-request',
defaultMessage: 'You have reached maximum request limit.',
},
unexpectedError: {
id: 'errors.unexpected-error',
defaultMessage: 'Something went wrong.',
},
});
/**
* map of http rest codes to ui label, used by fetcher to generate error with `-1` code
*/
export const httpErrorMap = new Map([
[401, 'Not authorized.'],
[403, 'Forbidden.'],
[429, 'You have reached maximum request limit.'],
[401, httpMessages.notAuthorized],
[403, httpMessages.forbidden],
[429, httpMessages.toManyRequest],
]);
export function isFailedFetch(e?: Error): boolean {
return Boolean(e && e.message && e.message === `Failed to fetch`);
}
export type FetcherError =
| string
| {
@@ -46,9 +198,8 @@ export type FetcherError =
error: string;
};
export function extractErrorMessageFromResponse(response: FetcherError): string {
const defaultErrorMessage = errorMessageForCodes.get(0) as string;
export function extractErrorMessageFromResponse(response: FetcherError, intl: IntlShape): string {
const defaultErrorMessage = intl.formatMessage(errorMessageForCodes.get(0) || messages['0']);
if (!response) {
return defaultErrorMessage;
}
@@ -57,16 +208,13 @@ export function extractErrorMessageFromResponse(response: FetcherError): string
return response;
}
if (response.code === -1) {
return response.error;
}
if (typeof response.details === 'string') {
return response.details.charAt(0).toUpperCase() + response.details.substring(1);
}
if (typeof response.code === 'number' && errorMessageForCodes.has(response.code)) {
return errorMessageForCodes.get(response.code)!;
if (
typeof response.code === 'number' &&
(errorMessageForCodes.has(response.code) || httpErrorMap.has(response.code))
) {
const messageDescriptor =
errorMessageForCodes.get(response.code) || httpErrorMap.get(response.code) || messages['0'];
return intl.formatMessage(messageDescriptor);
}
return defaultErrorMessage;
+4
View File
@@ -0,0 +1,4 @@
import { LastCommentsConfig } from '@app/common/config-types';
export function getLocale(params: { [key: string]: string } | LastCommentsConfig): string {
return params.locale || 'en';
}
+18
View File
@@ -0,0 +1,18 @@
/** this is generated file by "npm run generate-langs" **/
// it is ok that is empty. Default messages from code will be used.
const enMessages = {};
export async function loadLocale(locale: string): Promise<Record<string, string>> {
if (locale === 'ru') {
return import(/* webpackChunkName: "ru" */ '../locales/ru.json')
.then(res => res.default)
.catch(() => enMessages);
}
if (locale === 'de') {
return import(/* webpackChunkName: "de" */ '../locales/de.json')
.then(res => res.default)
.catch(() => enMessages);
}
return enMessages;
}
+2 -1
View File
@@ -126,7 +126,8 @@
host: window.location.origin,
url: 'https://remark42.com/demo/',
components: ['embed', 'counter'],
theme: theme
theme: theme,
// locale: "ru"
};
(function(c, d) {
+1
View File
@@ -15,4 +15,5 @@ module.exports = {
'^react-dom$': 'preact/compat',
},
setupFilesAfterEnv: ['<rootDir>/app/testUtils/index.ts'],
transformIgnorePatterns: ['/node_modules/(?!intl-messageformat|intl-messageformat-parser).+\\.js$'],
};
+713 -6
View File
@@ -171,6 +171,164 @@
}
}
},
"@babel/helper-create-class-features-plugin": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.8.3.tgz",
"integrity": "sha512-qmp4pD7zeTxsv0JNecSBsEmG1ei2MqwJq4YQcK3ZWm/0t07QstWfvuV/vm3Qt5xNMFETn2SZqpMx2MQzbtq+KA==",
"dev": true,
"requires": {
"@babel/helper-function-name": "^7.8.3",
"@babel/helper-member-expression-to-functions": "^7.8.3",
"@babel/helper-optimise-call-expression": "^7.8.3",
"@babel/helper-plugin-utils": "^7.8.3",
"@babel/helper-replace-supers": "^7.8.3",
"@babel/helper-split-export-declaration": "^7.8.3"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz",
"integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==",
"dev": true,
"requires": {
"@babel/highlight": "^7.8.3"
}
},
"@babel/generator": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.8.4.tgz",
"integrity": "sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3",
"jsesc": "^2.5.1",
"lodash": "^4.17.13",
"source-map": "^0.5.0"
}
},
"@babel/helper-function-name": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz",
"integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==",
"dev": true,
"requires": {
"@babel/helper-get-function-arity": "^7.8.3",
"@babel/template": "^7.8.3",
"@babel/types": "^7.8.3"
}
},
"@babel/helper-get-function-arity": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz",
"integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3"
}
},
"@babel/helper-member-expression-to-functions": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz",
"integrity": "sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3"
}
},
"@babel/helper-optimise-call-expression": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz",
"integrity": "sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3"
}
},
"@babel/helper-plugin-utils": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
"dev": true
},
"@babel/helper-replace-supers": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz",
"integrity": "sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA==",
"dev": true,
"requires": {
"@babel/helper-member-expression-to-functions": "^7.8.3",
"@babel/helper-optimise-call-expression": "^7.8.3",
"@babel/traverse": "^7.8.3",
"@babel/types": "^7.8.3"
}
},
"@babel/helper-split-export-declaration": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz",
"integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3"
}
},
"@babel/highlight": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz",
"integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==",
"dev": true,
"requires": {
"chalk": "^2.0.0",
"esutils": "^2.0.2",
"js-tokens": "^4.0.0"
}
},
"@babel/parser": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.8.4.tgz",
"integrity": "sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw==",
"dev": true
},
"@babel/template": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.3.tgz",
"integrity": "sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@babel/parser": "^7.8.3",
"@babel/types": "^7.8.3"
}
},
"@babel/traverse": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.8.4.tgz",
"integrity": "sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@babel/generator": "^7.8.4",
"@babel/helper-function-name": "^7.8.3",
"@babel/helper-split-export-declaration": "^7.8.3",
"@babel/parser": "^7.8.4",
"@babel/types": "^7.8.3",
"debug": "^4.1.0",
"globals": "^11.1.0",
"lodash": "^4.17.13"
}
},
"@babel/types": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.8.3.tgz",
"integrity": "sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==",
"dev": true,
"requires": {
"esutils": "^2.0.2",
"lodash": "^4.17.13",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/helper-define-map": {
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.5.5.tgz",
@@ -507,6 +665,24 @@
"@babel/plugin-syntax-async-generators": "^7.2.0"
}
},
"@babel/plugin-proposal-class-properties": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.8.3.tgz",
"integrity": "sha512-EqFhbo7IosdgPgZggHaNObkmO1kNUe3slaKu54d5OWvy+p9QIKOzK1GAEpAIsZtWVtPXUHSMcT4smvDrCfY4AA==",
"dev": true,
"requires": {
"@babel/helper-create-class-features-plugin": "^7.8.3",
"@babel/helper-plugin-utils": "^7.8.3"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
"dev": true
}
}
},
"@babel/plugin-proposal-dynamic-import": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz",
@@ -547,6 +723,24 @@
"@babel/plugin-syntax-optional-catch-binding": "^7.2.0"
}
},
"@babel/plugin-proposal-optional-chaining": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.8.3.tgz",
"integrity": "sha512-QIoIR9abkVn+seDE3OjA08jWcs3eZ9+wJCKSRgo3WdEU2csFYgdScb+8qHB3+WXsGJD55u+5hWCISI7ejXS+kg==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.8.3",
"@babel/plugin-syntax-optional-chaining": "^7.8.0"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
"dev": true
}
}
},
"@babel/plugin-proposal-unicode-property-regex": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.6.2.tgz",
@@ -612,6 +806,40 @@
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-syntax-optional-chaining": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
"integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.8.0"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
"dev": true
}
}
},
"@babel/plugin-syntax-typescript": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.8.3.tgz",
"integrity": "sha512-GO1MQ/SGGGoiEXY0e0bSpHimJvxqB7lktLLIq2pv8xG7WZ8IMEle74jIe1FhprHBWjwjZtXHkycDLZXIWM5Wfg==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.8.3"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
"dev": true
}
}
},
"@babel/plugin-transform-arrow-functions": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
@@ -949,6 +1177,25 @@
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-transform-typescript": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.8.3.tgz",
"integrity": "sha512-Ebj230AxcrKGZPKIp4g4TdQLrqX95TobLUWKd/CwG7X1XHUH1ZpkpFvXuXqWbtGRWb7uuEWNlrl681wsOArAdQ==",
"dev": true,
"requires": {
"@babel/helper-create-class-features-plugin": "^7.8.3",
"@babel/helper-plugin-utils": "^7.8.3",
"@babel/plugin-syntax-typescript": "^7.8.3"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
"dev": true
}
}
},
"@babel/plugin-transform-unicode-regex": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.6.2.tgz",
@@ -1044,6 +1291,24 @@
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
}
},
"@babel/preset-typescript": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.8.3.tgz",
"integrity": "sha512-qee5LgPGui9zQ0jR1TeU5/fP9L+ovoArklEqY12ek8P/wV5ZeM/VYSQYwICeoT6FfpJTekG9Ilay5PhwsOpMHA==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.8.3",
"@babel/plugin-transform-typescript": "^7.8.3"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz",
"integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==",
"dev": true
}
}
},
"@babel/runtime": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz",
@@ -1137,6 +1402,93 @@
"minimist": "^1.2.0"
}
},
"@formatjs/cli": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/@formatjs/cli/-/cli-1.1.20.tgz",
"integrity": "sha512-0seqDTfTTzXikRYic5arhKI27b4sDpdtGsbA/WG5BMPUYRdGZ38xBUD7V8pueFXlGiIR+SVKqY2kHP0X3Y5b3Q==",
"dev": true,
"requires": {
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-optional-chaining": "7",
"@babel/preset-env": "^7.6.0",
"@babel/preset-typescript": "^7.6.0",
"@types/babel__core": "^7.1.3",
"@types/loader-utils": "^1.1.3",
"@types/lodash": "^4.14.138",
"babel-plugin-const-enum": "^0.0.2",
"babel-plugin-react-intl": "^5.1.18",
"commander": "4.0.0-1",
"fs-extra": "^8.1.0",
"glob": "^7.1.6",
"loader-utils": "^1.2.3",
"lodash": "^4.17.15",
"loud-rejection": "^2.1.0"
},
"dependencies": {
"commander": {
"version": "4.0.0-1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.0.0-1.tgz",
"integrity": "sha512-6UCnFDyJnZfk4ZugvEhl8hYtlViGJNVki5J+3x/zNd8nLDpBcDHZShvBYWKIIktsiBUuRDttP/qG1yF+bhhVnQ==",
"dev": true
},
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"@formatjs/intl-displaynames": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-1.2.0.tgz",
"integrity": "sha512-mUGI2sc6OABkrMj42HlOpK1h96EVrN+gOhzbyCTMH9SVH/gPPLr/zFRH3KFWtBwxqhYsDghvUwm8xkdFOK0kTg==",
"requires": {
"@formatjs/intl-utils": "^2.2.0"
}
},
"@formatjs/intl-listformat": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-1.4.1.tgz",
"integrity": "sha512-AX0o1y5xXyMY4ebZOO+UujMcDhniYDs50KpwGzjUPV+bBILwRYqH/6IprZZG/V8YSOtetZlalZiwzJ50dH6PuQ==",
"requires": {
"@formatjs/intl-utils": "^2.2.0"
}
},
"@formatjs/intl-relativetimeformat": {
"version": "4.5.9",
"resolved": "https://registry.npmjs.org/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-4.5.9.tgz",
"integrity": "sha512-6rgPXQl5MrPPbCuNiHxolzO6xNCHphCVEWW6RWGy7t/Mek70gD7nq1erW8fbQJ0XL/UeAC0Cz/+ggh7vaSsKNA==",
"requires": {
"@formatjs/intl-utils": "^2.2.0"
}
},
"@formatjs/intl-unified-numberformat": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-unified-numberformat/-/intl-unified-numberformat-3.2.0.tgz",
"integrity": "sha512-SZMTV/tR0h7nYhS2x69S7zhHXaBmE0ZTR2OIiakt8W7uYWVgcRhu/LgUeVtGzpwPI2ChcOjNMtX/k6y1M9aDNA==",
"requires": {
"@formatjs/intl-utils": "^2.2.0"
}
},
"@formatjs/intl-utils": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.2.0.tgz",
"integrity": "sha512-+Az7tR1av1DHZu9668D8uh9atT6vp+FFmEF8BrEssv0OqzpVjpVBGVmcgPzQP8k2PQjVlm/h2w8cTt0knn132w=="
},
"@formatjs/macro": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@formatjs/macro/-/macro-0.2.6.tgz",
"integrity": "sha512-DfdnLJf8+PwLHzJECZ1Xfa8+sI9akQnUuLN2UdkaExTQmlY0Vs36rMzEP0JoVDBMk+KdQbJNt72rPeZkBNcKWg=="
},
"@github/combobox-nav": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-1.0.1.tgz",
@@ -1857,12 +2209,16 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
"integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
"dev": true,
"requires": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
}
},
"@types/invariant": {
"version": "2.2.31",
"resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.31.tgz",
"integrity": "sha512-jMlgg9pIURvy9jgBHCjQp/CyBjYHUwj91etVcDdXkFl2CwTFiQlB+8tcsMeXpXf2PFE5X2pjk4Gm43hQSMHAdA=="
},
"@types/istanbul-lib-coverage": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
@@ -1909,6 +2265,16 @@
"integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==",
"dev": true
},
"@types/loader-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/loader-utils/-/loader-utils-1.1.3.tgz",
"integrity": "sha512-euKGFr2oCB3ASBwG39CYJMR3N9T0nanVqXdiH7Zu/Nqddt6SmFRxytq/i2w9LQYNQekEtGBz+pE3qG6fQTNvRg==",
"dev": true,
"requires": {
"@types/node": "*",
"@types/webpack": "*"
}
},
"@types/lodash": {
"version": "4.14.144",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.144.tgz",
@@ -1962,14 +2328,12 @@
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
"dev": true
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
},
"@types/react": {
"version": "16.9.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.11.tgz",
"integrity": "sha512-UBT4GZ3PokTXSWmdgC/GeCGEJXE5ofWyibCcecRLUVN2ZBpXQGVgQGtG2foS7CrTKFKlQVVswLvf7Js6XA/CVQ==",
"dev": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^2.2.0"
@@ -1996,6 +2360,12 @@
"redux": "^4.0.0"
}
},
"@types/schema-utils": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/schema-utils/-/schema-utils-1.0.0.tgz",
"integrity": "sha512-YesPanU1+WCigC/Aj1Mga8UCOjHIfMNHZ3zzDsUY7lI8GlKnh/Kv2QwJOQ+jNQ36Ru7IfzSedlG14hppYaN13A==",
"dev": true
},
"@types/stack-utils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
@@ -2554,6 +2924,12 @@
"integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=",
"dev": true
},
"array-find-index": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
"integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=",
"dev": true
},
"array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2972,6 +3348,16 @@
}
}
},
"babel-plugin-const-enum": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-const-enum/-/babel-plugin-const-enum-0.0.2.tgz",
"integrity": "sha512-jTa4A/b2sTM++neJnNZRi6CL7imluFkepD7mB+IpndQy/5LKwPpuoIfSY0nC94Y/nnxjhNRAW2fdBgT5dI4/+w==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.0.0",
"@babel/plugin-syntax-typescript": "^7.3.3"
}
},
"babel-plugin-dynamic-import-node": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz",
@@ -3002,6 +3388,206 @@
"@types/babel__traverse": "^7.0.6"
}
},
"babel-plugin-react-intl": {
"version": "5.1.18",
"resolved": "https://registry.npmjs.org/babel-plugin-react-intl/-/babel-plugin-react-intl-5.1.18.tgz",
"integrity": "sha512-tzzZoGDNQOiHmGFh+NPQJDpC10RbKlfw1CBVfALulqRa6UGkAv5eMs9sirxjhD3HryHPbYZ4x5FNdbzOyG2GJw==",
"dev": true,
"requires": {
"@babel/core": "^7.7.2",
"@babel/helper-plugin-utils": "^7.0.0",
"@types/babel__core": "^7.1.3",
"@types/schema-utils": "^1.0.0",
"fs-extra": "^8.1.0",
"intl-messageformat-parser": "^3.6.4",
"schema-utils": "^2.2.0"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz",
"integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==",
"dev": true,
"requires": {
"@babel/highlight": "^7.8.3"
}
},
"@babel/core": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.8.4.tgz",
"integrity": "sha512-0LiLrB2PwrVI+a2/IEskBopDYSd8BCb3rOvH7D5tzoWd696TBEduBvuLVm4Nx6rltrLZqvI3MCalB2K2aVzQjA==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@babel/generator": "^7.8.4",
"@babel/helpers": "^7.8.4",
"@babel/parser": "^7.8.4",
"@babel/template": "^7.8.3",
"@babel/traverse": "^7.8.4",
"@babel/types": "^7.8.3",
"convert-source-map": "^1.7.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.1",
"json5": "^2.1.0",
"lodash": "^4.17.13",
"resolve": "^1.3.2",
"semver": "^5.4.1",
"source-map": "^0.5.0"
}
},
"@babel/generator": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.8.4.tgz",
"integrity": "sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3",
"jsesc": "^2.5.1",
"lodash": "^4.17.13",
"source-map": "^0.5.0"
}
},
"@babel/helper-function-name": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz",
"integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==",
"dev": true,
"requires": {
"@babel/helper-get-function-arity": "^7.8.3",
"@babel/template": "^7.8.3",
"@babel/types": "^7.8.3"
}
},
"@babel/helper-get-function-arity": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz",
"integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3"
}
},
"@babel/helper-split-export-declaration": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz",
"integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==",
"dev": true,
"requires": {
"@babel/types": "^7.8.3"
}
},
"@babel/helpers": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.8.4.tgz",
"integrity": "sha512-VPbe7wcQ4chu4TDQjimHv/5tj73qz88o12EPkO2ValS2QiQS/1F2SsjyIGNnAD0vF/nZS6Cf9i+vW6HIlnaR8w==",
"dev": true,
"requires": {
"@babel/template": "^7.8.3",
"@babel/traverse": "^7.8.4",
"@babel/types": "^7.8.3"
}
},
"@babel/highlight": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz",
"integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==",
"dev": true,
"requires": {
"chalk": "^2.0.0",
"esutils": "^2.0.2",
"js-tokens": "^4.0.0"
}
},
"@babel/parser": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.8.4.tgz",
"integrity": "sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw==",
"dev": true
},
"@babel/template": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.3.tgz",
"integrity": "sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@babel/parser": "^7.8.3",
"@babel/types": "^7.8.3"
}
},
"@babel/traverse": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.8.4.tgz",
"integrity": "sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@babel/generator": "^7.8.4",
"@babel/helper-function-name": "^7.8.3",
"@babel/helper-split-export-declaration": "^7.8.3",
"@babel/parser": "^7.8.4",
"@babel/types": "^7.8.3",
"debug": "^4.1.0",
"globals": "^11.1.0",
"lodash": "^4.17.13"
}
},
"@babel/types": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.8.3.tgz",
"integrity": "sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==",
"dev": true,
"requires": {
"esutils": "^2.0.2",
"lodash": "^4.17.13",
"to-fast-properties": "^2.0.0"
}
},
"ajv": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.11.0.tgz",
"integrity": "sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"ajv-keywords": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz",
"integrity": "sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==",
"dev": true
},
"convert-source-map": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz",
"integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.1"
}
},
"fast-deep-equal": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
"integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==",
"dev": true
},
"schema-utils": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.4.tgz",
"integrity": "sha512-VNjcaUxVnEeun6B2fiiUDjXXBtD4ZSH7pdbfIu1pOFwgptDPLMo/z9jr4sUfsjFVPqDCEin/F7IYlq7/E6yDbQ==",
"dev": true,
"requires": {
"ajv": "^6.10.2",
"ajv-keywords": "^3.4.1"
}
}
}
},
"babel-polyfill": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz",
@@ -4537,8 +5123,7 @@
"csstype": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
"integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ==",
"dev": true
"integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ=="
},
"cuint": {
"version": "0.2.2",
@@ -4546,6 +5131,15 @@
"integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs=",
"dev": true
},
"currently-unhandled": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
"integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=",
"dev": true,
"requires": {
"array-find-index": "^1.0.1"
}
},
"cycle": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz",
@@ -6263,6 +6857,25 @@
"readable-stream": "^2.0.0"
}
},
"fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"dependencies": {
"graceful-fs": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==",
"dev": true
}
}
},
"fs-write-stream-atomic": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
@@ -6931,6 +7544,12 @@
"lodash.padstart": "^4.1.0"
}
},
"gensync": {
"version": "1.0.0-beta.1",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz",
"integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==",
"dev": true
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -7883,6 +8502,33 @@
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.7.0.tgz",
"integrity": "sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg=="
},
"intl-format-cache": {
"version": "4.2.21",
"resolved": "https://registry.npmjs.org/intl-format-cache/-/intl-format-cache-4.2.21.tgz",
"integrity": "sha512-6pZlBdqTRUuuwRWywPItHY1JQwzQxWcpBHv6w4M8T6bGzAsiL/QmI+XsdOhsqJLaL4ZmTATn1kIkNlMk4VzSLQ=="
},
"intl-locales-supported": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/intl-locales-supported/-/intl-locales-supported-1.8.4.tgz",
"integrity": "sha512-wO0JhDqhshhkq8Pa9CLcstqd1aCXjfMgfMzjD6mDreS3mTSDbjGiMU+07O8BdJGxed7Q0Wf3TFVjGq0W3Y0n1w=="
},
"intl-messageformat": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-7.8.4.tgz",
"integrity": "sha512-yS0cLESCKCYjseCOGXuV4pxJm/buTfyCJ1nzQjryHmSehlptbZbn9fnlk1I9peLopZGGbjj46yHHiTAEZ1qOTA==",
"requires": {
"intl-format-cache": "^4.2.21",
"intl-messageformat-parser": "^3.6.4"
}
},
"intl-messageformat-parser": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/intl-messageformat-parser/-/intl-messageformat-parser-3.6.4.tgz",
"integrity": "sha512-RgPGwue0mJtoX2Ax8EmMzJzttxjnva7gx0Q7mKJ4oALrTZvtmCeAw5Msz2PcjW4dtCh/h7vN/8GJCxZO1uv+OA==",
"requires": {
"@formatjs/intl-unified-numberformat": "^3.2.0"
}
},
"invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -9982,6 +10628,15 @@
"minimist": "^1.2.0"
}
},
"jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
"dev": true,
"requires": {
"graceful-fs": "^4.1.6"
}
},
"jsonify": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
@@ -10790,6 +11445,16 @@
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"loud-rejection": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-2.2.0.tgz",
"integrity": "sha512-S0FayMXku80toa5sZ6Ro4C+s+EtFDCsyJNG/AzFMfX3AxD5Si4dZsgzm/kKnbOxHl5Cv8jBlno8+3XYIh2pNjQ==",
"dev": true,
"requires": {
"currently-unhandled": "^0.4.1",
"signal-exit": "^3.0.2"
}
},
"lower-case": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz",
@@ -13156,6 +13821,37 @@
"unpipe": "1.0.0"
}
},
"react-intl": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-3.12.0.tgz",
"integrity": "sha512-VQWkFYSKKoi85p3gOXgG80KkBImdBJXwJxssO9gqdelW/fuVnxQLXgYOKuOqWrUz5beXK+qBve6bTpblh1ep2g==",
"requires": {
"@formatjs/intl-displaynames": "^1.2.0",
"@formatjs/intl-listformat": "^1.3.7",
"@formatjs/intl-relativetimeformat": "^4.5.7",
"@formatjs/intl-unified-numberformat": "^3.0.4",
"@formatjs/intl-utils": "^2.0.4",
"@formatjs/macro": "^0.2.6",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/invariant": "^2.2.31",
"hoist-non-react-statics": "^3.3.1",
"intl-format-cache": "^4.2.19",
"intl-locales-supported": "^1.8.4",
"intl-messageformat": "^7.8.2",
"intl-messageformat-parser": "^3.6.2",
"shallow-equal": "^1.2.1"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"requires": {
"react-is": "^16.7.0"
}
}
}
},
"react-is": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
@@ -13974,6 +14670,11 @@
"safe-buffer": "^5.0.1"
}
},
"shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
},
"shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
@@ -16291,6 +16992,12 @@
"unist-util-is": "^3.0.0"
}
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true
},
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+6 -2
View File
@@ -6,10 +6,12 @@
"start": "webpack-dev-server --mode development",
"check": "tsc -p tsconfig.json --noEmit --skipLibCheck",
"lint": "eslint --max-warnings=0 --ext=.ts,.tsx,.js,.jsx .",
"lint:style": "stylelint \"**/*.scss\" \"**/*.pcss\" \"**/*.css\" \"iframe.html\"",
"lint:style": "stylelint '**/*.scss' '**/*.pcss' '**/*.css' 'iframe.html'",
"test": "jest",
"test:coverage": "jest --coverage",
"prettier": "prettier --write \"./**/*.{js,jsx,ts,tsx,scss}\"",
"prettier": "prettier --write './**/*.{js,jsx,ts,tsx,scss}'",
"extract-messages": "formatjs extract --out-file=./extracted-messages/messages.json './app/**/*.{js,jsx,ts,tsx}'",
"generate-langs": "npm run extract-messages && node ./tasks/generateDictionary.js",
"size": "NODE_ENV=production npm run build && size-limit"
},
"husky": {
@@ -25,6 +27,7 @@
"@babel/plugin-transform-react-jsx": "^7.3.0",
"@babel/preset-env": "^7.6.3",
"@babel/preset-react": "^7.6.3",
"@formatjs/cli": "^1.1.20",
"@size-limit/file": "^4.0.1",
"@types/cheerio": "^0.22.13",
"@types/core-js": "^2.5.2",
@@ -107,6 +110,7 @@
"lodash-es": "^4.17.15",
"node-emoji": "^1.10.0",
"preact": "^10.0.1",
"react-intl": "^3.12.0",
"react-redux": "^7.1.1",
"redux": "^4.0.4",
"redux-thunk": "^2.3.0"
+46
View File
@@ -0,0 +1,46 @@
const fs = require('fs');
const path = require('path');
const defaultMessages = require('../extracted-messages/messages');
const { renderLoadLocale } = require('./localeLoadTemplate');
const { getSupportedLocales } = require('./getSupportedLocales');
const locales = getSupportedLocales();
const keyMessagePairs = [];
const keysSet = new Set();
defaultMessages.forEach(({ id, defaultMessage }) => {
keyMessagePairs.push([id, defaultMessage]);
keysSet.add(id);
});
function removeAbandonedKeys(existKeys, dictionary) {
return Object.fromEntries(Object.entries(dictionary).filter(([key]) => existKeys.has(key)));
}
function sortDict(dict) {
return Object.fromEntries(
Object.entries(dict).sort(([a], [b]) => {
return a.localeCompare(b);
})
);
}
locales.forEach(locale => {
let currentDict = {};
const pathToDict = path.resolve(__dirname, `../app/locales/${locale}.json`);
if (fs.existsSync(pathToDict)) {
currentDict = require(pathToDict);
}
keyMessagePairs.forEach(([key, defaultMessage]) => {
if (!currentDict[key] || locale === `en`) {
currentDict[key] = defaultMessage;
}
});
currentDict = removeAbandonedKeys(keysSet, currentDict);
currentDict = sortDict(currentDict);
fs.writeFileSync(pathToDict, JSON.stringify(currentDict, null, 2) + '\n');
fs.writeFileSync(
path.resolve(__dirname, `../app/utils/loadLocale.ts`),
renderLoadLocale(locales.filter(locale => locale !== 'en'))
);
});
+6
View File
@@ -0,0 +1,6 @@
const getSupportedLocales = require('./supportedLocales');
module.exports = {
getSupportedLocales() {
return getSupportedLocales;
},
};
+21
View File
@@ -0,0 +1,21 @@
function renderLoadLocale(locales) {
return `/** this is generated file by "npm run generate-langs" **/
// it is ok that is empty. Default messages from code will be used.
const enMessages = {};
export async function loadLocale(locale: string): Promise<Record<string, string>> {
${locales
.map(
locale => ` if (locale === '${locale}') {
return import(/* webpackChunkName: "${locale}" */ '../locales/${locale}.json')
.then(res => res.default)
.catch(() => enMessages);
}
`
)
.join('')}
return enMessages;
}\n`;
}
module.exports = { renderLoadLocale };
+1
View File
@@ -0,0 +1 @@
["en", "ru", "de"]
+1
View File
@@ -16,6 +16,7 @@
"baseUrl": "./",
"alwaysStrict": true,
"sourceMap": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"*": ["@types/*"],
+9 -2
View File
@@ -27,7 +27,14 @@ console.log(`REMARK_ENV = ${remarkUrl}`);
* so we have to exclude from ignore these modules
*/
function getExcluded() {
const modules = ['@github/markdown-toolbar-element', '@github/text-expander-element', '@github/combobox-nav'];
const modules = [
'@github/markdown-toolbar-element',
'@github/text-expander-element',
'@github/combobox-nav',
'react-intl',
'intl-messageformat',
'intl-messageformat-parser',
];
const exclude = new RegExp(`node_modules\\/(?!(${modules.map(m => m.replace(/\//g, '\\/')).join('|')})\\/).*`);
return {
@@ -203,7 +210,7 @@ module.exports = () => ({
},
devServer: {
host: '0.0.0.0',
port: 9000,
port: process.env.PORT || 9000,
contentBase: publicFolder,
publicPath: '/web',
disableHostCheck: true,