From b22cd5f76a297ae34a3e09f9c2ea8086ab4582de Mon Sep 17 00:00:00 2001 From: Umputun Date: Wed, 4 Mar 2020 14:56:00 -0600 Subject: [PATCH] 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 Co-authored-by: Pavel Mineev --- .dockerignore | 2 +- README.md | 9 +- frontend/.gitignore | 1 + frontend/.size-limit.js | 4 +- frontend/Readme.md | 41 +- frontend/app/common/config-types.ts | 2 + frontend/app/common/constants.ts | 21 +- frontend/app/common/fetcher.test.ts | 6 +- frontend/app/common/fetcher.ts | 78 +- .../auth-panel__anonymous-login-form.tsx | 29 +- .../auth-panel__email-login-form.test.tsx | 84 +- .../auth-panel__email-login-form.tsx | 88 ++- .../components/auth-panel/auth-panel.test.tsx | 86 ++- .../app/components/auth-panel/auth-panel.tsx | 132 +++- frontend/app/components/auth-panel/index.ts | 2 +- .../comment-form__subscribe-by-email.test.tsx | 24 +- .../comment-form__subscribe-by-email.tsx | 86 ++- .../comment-form__subscribe-by-rss.test.tsx | 12 + .../comment-form__subscribe-by-rss.tsx | 35 +- .../comment-form/comment-form.test.tsx | 14 +- .../components/comment-form/comment-form.tsx | 123 ++- .../comment-form/markdown-toolbar.tsx | 61 +- .../app/components/comment/comment.test.tsx | 32 +- frontend/app/components/comment/comment.tsx | 266 +++++-- .../comment/getBlockingDurations.ts | 42 + .../app/components/comment/getVoteMessage.ts | 55 ++ frontend/app/components/root/root.tsx | 39 +- frontend/app/components/settings/settings.tsx | 99 ++- frontend/app/components/thread/thread.tsx | 3 + .../user-info/last-comments-list.tsx | 1 - .../app/components/user-info/user-info.tsx | 23 +- frontend/app/last-comments.tsx | 13 +- frontend/app/locales/de.json | 167 ++++ frontend/app/locales/en.json | 167 ++++ frontend/app/locales/ru.json | 167 ++++ frontend/app/remark.tsx | 29 +- frontend/app/utils/errorUtils.ts | 222 +++++- frontend/app/utils/getLocale.ts | 4 + frontend/app/utils/loadLocale.ts | 18 + frontend/index.ejs | 3 +- frontend/jest.config.js | 1 + frontend/package-lock.json | 719 +++++++++++++++++- frontend/package.json | 8 +- frontend/tasks/generateDictionary.js | 46 ++ frontend/tasks/getSupportedLocales.js | 6 + frontend/tasks/localeLoadTemplate.js | 21 + frontend/tasks/supportedLocales.json | 1 + frontend/tsconfig.json | 1 + frontend/webpack.config.js | 11 +- 49 files changed, 2640 insertions(+), 464 deletions(-) create mode 100644 frontend/app/components/comment/getBlockingDurations.ts create mode 100644 frontend/app/components/comment/getVoteMessage.ts create mode 100644 frontend/app/locales/de.json create mode 100644 frontend/app/locales/en.json create mode 100644 frontend/app/locales/ru.json create mode 100644 frontend/app/utils/getLocale.ts create mode 100644 frontend/app/utils/loadLocale.ts create mode 100644 frontend/tasks/generateDictionary.js create mode 100644 frontend/tasks/getSupportedLocales.js create mode 100644 frontend/tasks/localeLoadTemplate.js create mode 100644 frontend/tasks/supportedLocales.json diff --git a/.dockerignore b/.dockerignore index c016742c..cb9b579f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,4 +21,4 @@ debug.test *.test remark42 /backend/var/ -compose-private-backend.yml \ No newline at end of file +compose-private-backend.yml diff --git a/README.md b/README.md index f3758ac2..2add0134 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/frontend/.gitignore b/frontend/.gitignore index 4c49bd78..355e175c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1 +1,2 @@ .env +extracted-messages diff --git a/frontend/.size-limit.js b/frontend/.size-limit.js index fcb67b7c..92941ff8 100644 --- a/frontend/.size-limit.js +++ b/frontend/.size-limit.js @@ -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', }, { diff --git a/frontend/Readme.md b/frontend/Readme.md index 402bbcd2..0d2585ef 100644 --- a/frontend/Readme.md +++ b/frontend/Readme.md @@ -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/.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` \ No newline at end of file +- 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` diff --git a/frontend/app/common/config-types.ts b/frontend/app/common/config-types.ts index 078d1e3e..5ee93b5b 100644 --- a/frontend/app/common/config-types.ts +++ b/frontend/app/common/config-types.ts @@ -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; } diff --git a/frontend/app/common/constants.ts b/frontend/app/common/constants.ts index 6dae6b15..671d4f24 100644 --- a/frontend/app/common/constants.ts +++ b/frontend/app/common/constants.ts @@ -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); diff --git a/frontend/app/common/fetcher.test.ts b/frontend/app/common/fetcher.test.ts index d45be047..ca1fbdaa 100644 --- a/frontend/app/common/fetcher.test.ts +++ b/frontend/app/common/fetcher.test.ts @@ -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'); }); }); }); diff --git a/frontend/app/common/fetcher.ts b/frontend/app/common/fetcher.ts index 8f0fe962..04f3ede3 100644 --- a/frontend/app/common/fetcher.ts +++ b/frontend/app/common/fetcher.ts @@ -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>((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; diff --git a/frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.tsx b/frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.tsx index cc1783ef..5f58dd09 100644 --- a/frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.tsx +++ b/frontend/app/components/auth-panel/__anonymous-login-form/auth-panel__anonymous-login-form.tsx @@ -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; 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 { static usernameRegex = /^[a-zA-Z][\w ]+$/; @@ -51,9 +69,9 @@ export class AnonymousLoginForm extends Component { 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 { 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 { @@ -103,7 +122,7 @@ export class AnonymousLoginForm extends Component { title={usernameInvalidReason || ''} disabled={usernameInvalidReason !== null} > - Log in + ); diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx index 7cebf6d2..73bdbce0 100644 --- a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx +++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx @@ -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().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( - + + + ); - - 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( - - ); - await new Promise(resolve => - wrapper.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve) + + + ); + 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().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( - - ); - await new Promise(resolve => - wrapper.setState({ usernameValue: 'someone', addressValue: 'someone@example.com' } as State, resolve) + + + ); + 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(); diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx index 598c6c93..918eb53c 100644 --- a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx +++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx @@ -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; @@ -24,7 +22,7 @@ interface OwnProps { className?: string; } -export type Props = OwnProps & ReturnType; +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 { static usernameRegex = /^[a-zA-Z][\w ]+$/; static emailRegex = /[^@]+@[^.]+\..+/; @@ -70,24 +99,27 @@ export class EmailLoginForm extends Component { 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 { }; 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 { }; 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 {
@@ -191,7 +228,7 @@ export class EmailLoginForm extends Component { title={form1InvalidReason || ''} disabled={form1InvalidReason !== null} > - Send Verification + ); @@ -201,13 +238,14 @@ export class EmailLoginForm extends Component { return (
{ title={form2InvalidReason || ''} disabled={form2InvalidReason !== null} > - Confirm + ); @@ -232,6 +270,6 @@ export class EmailLoginForm extends Component { export type EmailLoginFormRef = EmailLoginForm; export const EmailLoginFormConnected = forwardRef((props, ref) => { - const connectedProps = useSelector(mapStateToProps); - return ; + const intl = useIntl(); + return ; }); diff --git a/frontend/app/components/auth-panel/auth-panel.test.tsx b/frontend/app/components/auth-panel/auth-panel.test.tsx index c1e41a01..a3597c51 100644 --- a/frontend/app/components/auth-panel/auth-panel.test.tsx +++ b/frontend/app/components/auth-panel/auth-panel.test.tsx @@ -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 = { sort: '-score', @@ -22,7 +24,11 @@ const DefaultProps: Partial = { describe('', () => { describe('For not authorized user', () => { it('should render login form with google and github provider', () => { - const element = mount(); + const element = mount( + + + + ); const authPanelColumn = element.find('.auth-panel__column'); @@ -41,12 +47,14 @@ describe('', () => { describe('sorting', () => { it('should place selected provider first', () => { const element = mount( - + + + ); const providerLinks = element @@ -61,12 +69,14 @@ describe('', () => { it('should do nothing if provider not found', () => { const element = mount( - + + + ); const providerLinks = element @@ -82,11 +92,13 @@ describe('', () => { it('should render login form with google and github provider for read-only post', () => { const element = mount( - + + + ); const authPanelColumn = element.find('.auth-panel__column'); @@ -105,11 +117,13 @@ describe('', () => { it('should not render settings if there is no hidden users', () => { const element = mount( - + + + ); const adminAction = element.find('.auth-panel__admin-action'); @@ -119,12 +133,14 @@ describe('', () => { it('should render settings if there is some hidden users', () => { const element = mount( - + + + ); const adminAction = element.find('.auth-panel__admin-action'); @@ -134,7 +150,11 @@ describe('', () => { }); describe('For authorized user', () => { it('should render info about current user', () => { - const element = mount(); + const element = mount( + + + + ); const authPanelColumn = element.find('.auth-panel__column'); @@ -148,7 +168,9 @@ describe('', () => { describe('For admin user', () => { it('should render admin action', () => { const element = mount( - + + {' '} + ); const adminAction = element.find('.auth-panel__admin-action').first(); diff --git a/frontend/app/components/auth-panel/auth-panel.tsx b/frontend/app/components/auth-panel/auth-panel.tsx index 88cf2126..4a4a1e02 100644 --- a/frontend/app/components/auth-panel/auth-panel.tsx +++ b/frontend/app/components/auth-panel/auth-panel.tsx @@ -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 { emailLoginRef = createRef(); @@ -155,7 +173,7 @@ export class AuthPanel extends Component { return (
- You logged in as{' '} + {' '}
{ {!isUserAnonymous && ( )} {' '}
); @@ -184,9 +202,10 @@ export class AuthPanel extends Component { renderProvider = (provider: AuthProvider['name'], dropdown = false) => { if (provider === 'anonymous') { + const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider); return ( @@ -195,6 +214,7 @@ export class AuthPanel extends Component { onSubmit={this.handleAnonymousLoginFormSubmut} theme={this.props.theme} className="auth-panel__anonymous-login-form" + intl={this.props.intl} />
@@ -234,8 +254,9 @@ export class AuthPanel extends Component { }; renderOther = (providers: AuthProvider['name'][]) => { + const other = this.props.intl.formatMessage(authPanelMessages.otherProvider); return ( - + {providers.map(provider => ( {this.renderProvider(provider, true)} ))} @@ -260,13 +281,13 @@ export class AuthPanel extends Component { })(); const isAboveThreshold = sortedProviders.length > threshold; - + const or = this.props.intl.formatMessage(authPanelMessages.orProvider); return (
- {'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 ( @@ -288,7 +309,7 @@ export class AuthPanel extends Component { })} {isAboveThreshold && ( - {' or '} + {` ${or} `} {this.renderOther(sortedProviders.slice(threshold - 1))} )} @@ -300,7 +321,10 @@ export class AuthPanel extends Component { if (IS_STORAGE_AVAILABLE || !IS_THIRD_PARTY) return null; return (
- Disable third-party cookies blocking to login or open comments in{' '} + {' '} { renderCookiesWarning = () => { if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) return null; - return
Allow cookies to login and comment
; + return ( +
+ +
+ ); }; renderSettingsLabel = () => { @@ -325,7 +353,11 @@ export class AuthPanel extends Component { {...getHandleClickProps(() => this.toggleBlockedVisibility())} role="link" > - {this.state.isBlockedVisible ? 'Hide' : 'Show'} settings + {this.state.isBlockedVisible ? ( + + ) : ( + + )} ); }; @@ -339,7 +371,11 @@ export class AuthPanel extends Component { {...getHandleClickProps(() => this.toggleCommentsAvailability())} role="link" > - {isCommentsDisabled ? 'Enable' : 'Disable'} comments + {isCommentsDisabled ? ( + + ) : ( + + )} ); }; @@ -347,10 +383,10 @@ export class AuthPanel extends Component { renderSort = () => { const { sort } = this.props; const { sortSelectFocused } = this.state; - const sortArray = getSortArray(sort); + const sortArray = getSortArray(sort, this.props.intl); return ( - Sort by{' '} + {' '} {sortArray.find(x => 'selected' in x && x.selected!)!.label} @@ -396,7 +432,11 @@ export class AuthPanel extends Component { {isAdmin && ' • '} - {!isAdmin && read_only && Read-only} + {!isAdmin && read_only && ( + + + + )} {this.renderSort()}
@@ -405,7 +445,42 @@ export class AuthPanel extends Component { } } -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 ; +}; diff --git a/frontend/app/components/auth-panel/index.ts b/frontend/app/components/auth-panel/index.ts index 861405b4..00466edd 100644 --- a/frontend/app/components/auth-panel/index.ts +++ b/frontend/app/components/auth-panel/index.ts @@ -1,4 +1,4 @@ -export { AuthPanel } from './auth-panel'; +export { AuthPanelWithIntl as AuthPanel } from './auth-panel'; import './auth-panel.scss'; diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx index 78a760dd..2dc0324f 100644 --- a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx @@ -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('', () => { const createWrapper = (store: ReturnType = mockStore(initialStore)) => mount( - - - + + + + + ); it('should be rendered with disabled email button when user is anonymous', () => { @@ -67,9 +71,11 @@ describe('', () => { describe('', () => { const createWrapper = (store: ReturnType = mockStore(initialStore)) => mount( - - - + + + + + ); it('should render email form by default', () => { const store = mockStore(initialStore); @@ -130,7 +136,7 @@ describe('', () => { 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('', () => { 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('', () => { 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'); }); }); diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx index fca39616..2b341f0b 100644 --- a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx @@ -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 ) => ( -
Subscribe to replies
+
+ +
void, setEmailStep: () => void ) => ( { const theme = useTheme(); const dispatch = useDispatch(); + const intl = useIntl(); const subscribed = useSelector(({ 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 (
@@ -210,7 +255,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => { theme={theme} onClick={handleUnsubscribe} > - Unsubscribe +
); @@ -225,7 +270,10 @@ export const SubscribeByEmailForm: FunctionComponent = () => { return (
- You have been unsubscribed by email to updates +
); } - const buttonLabel = step === Step.Email ? 'Submit' : 'Subscribe'; + const buttonLabel = + step === Step.Email ? intl.formatMessage(messages.submit) : intl.formatMessage(messages.subscribe); return (
- {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 && (
{error} @@ -265,14 +314,17 @@ export const SubscribeByEmailForm: FunctionComponent = () => { export const SubscribeByEmail: FunctionComponent = () => { const theme = useTheme(); + const intl = useIntl(); const user = useSelector(({ 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 ( ({ 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('', () => { let wrapper: ReturnType; @@ -16,6 +27,7 @@ describe('', () => { }); it('should be render links in dropdown', () => { + wrapper.update(); expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3); }); diff --git a/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx index bd89560a..dc9e98cd 100644 --- a/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx +++ b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.tsx @@ -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 ( diff --git a/frontend/app/components/comment-form/comment-form.test.tsx b/frontend/app/components/comment-form/comment-form.test.tsx index 191e84ee..91dd09b1 100644 --- a/frontend/app/components/comment-form/comment-form.test.tsx +++ b/frontend/app/components/comment-form/comment-form.test.tsx @@ -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 = { +const DEFAULT_PROPS: Readonly> = { mode: 'main', theme: 'light', onSubmit: () => Promise.resolve(), @@ -16,9 +16,15 @@ const DEFAULT_PROPS: Readonly = { user: null, }; +const intl = { + formatMessage() { + return ''; + }, +} as any; + describe('', () => { 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(); expect(wrapper.exists('.comment-form__control-panel')).toEqual(false); @@ -29,7 +35,7 @@ describe('', () => { 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(); expect(wrapper.exists(SubscribeByEmail)).toEqual(true); @@ -38,7 +44,7 @@ describe('', () => { 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(); expect(wrapper.exists(SubscribeByEmail)).toEqual(false); diff --git a/frontend/app/components/comment-form/comment-form.tsx b/frontend/app/components/comment-form/comment-form.tsx index 0fb4a736..21e7fdf3 100644 --- a/frontend/app/components/comment-form/comment-form.tsx +++ b/frontend/app/components/comment-form/comment-form.tsx @@ -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; + 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 { /** reference to textarea element */ textAreaRef = createRef(); @@ -165,8 +192,7 @@ export class CommentForm extends Component { 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 { /** wrapper with error handling for props.uploadImage */ uploadImage(file: File): Promise { - 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 { 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 { 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 { 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 { }; 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 { render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) { const charactersLeft = maxLength - text.length; errorMessage = props.errorMessage || errorMessage; + const Labels = { + main: , + edit: , + reply: , + }; const label = buttonText || Labels[props.mode || 'main']; - + const intl = this.props.intl; + const placeholderMessage = intl.formatMessage(messages.placeholder); return ( { mix: props.mix, })} onSubmit={this.send} - aria-label="New comment" + aria-label={intl.formatMessage(messages.newComment)} onDragOver={this.onDragOver} onDrop={this.onDrop} > {!props.simpleView && (
{ 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 {
{(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 => (

{e}

@@ -406,7 +453,7 @@ export class CommentForm extends Component { disabled={isDisabled} onClick={this.getPreview} > - Preview + )}
) ); controls.push( ); } @@ -406,7 +497,7 @@ export class Comment extends Component { if (!isCurrentUser) { controls.push( ); } @@ -415,21 +506,20 @@ export class Comment extends Component { if (this.props.isUserBanned) { controls.push( ); } - + const blockingDurations = getBlockingDurations(this.props.intl); if (this.props.user!.id !== this.props.data.user.id && !this.props.isUserBanned) { controls.push( - Block + @@ -440,7 +530,7 @@ export class Comment extends Component { if (!this.props.data.delete) { controls.push( ); } @@ -464,22 +554,25 @@ export class Comment extends Component { 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 { /> ); } - + const goToParentMessage = intl.formatMessage(messages.goToParent); return (
{ {isAdmin && props.view !== 'user' && ( )} {!isAdmin && !!o.user.verified && props.view !== 'user' && ( - + )} - {o.time} + {!!props.level && props.level > 0 && props.view === 'main' && ( this.scrollToParent(e)} > {' '} )} - {props.isUserBanned && props.view !== 'user' && Blocked} + {props.isUserBanned && props.view !== 'user' && ( + + + + )} - {isAdmin && !props.isUserBanned && props.data.delete && Deleted} + {isAdmin && !props.isUserBanned && props.data.delete && ( + + + + )} {
{!!state.voteErrorMessage && (
- Voting error: {state.voteErrorMessage} +
)} @@ -676,7 +788,11 @@ export class Comment extends Component {
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && ( )} {!props.data.delete && @@ -690,7 +806,11 @@ export class Comment extends Component { {...getHandleClickProps(this.toggleEditing)} mix={['comment__action', 'comment__action_type_edit']} > - {isEditing ? 'Cancel' : 'Edit'} + {isEditing ? ( + + ) : ( + + )} , !isAdmin && ( ), state.editDeadline && ( @@ -721,6 +841,7 @@ export class Comment extends Component { {CommentForm && isReplying && props.view === 'main' && ( { {CommentForm && isEditing && props.view === 'main' && ( { 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 ( + + ); } + +const CommentWithIntl = (props: PropsWithoutIntl) => { + const intl = useIntl(); + return ; +}; + +export { CommentWithIntl as Comment }; diff --git a/frontend/app/components/comment/getBlockingDurations.ts b/frontend/app/components/comment/getBlockingDurations.ts new file mode 100644 index 00000000..11f09a44 --- /dev/null +++ b/frontend/app/components/comment/getBlockingDurations.ts @@ -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', + }, + ]; +} diff --git a/frontend/app/components/comment/getVoteMessage.ts b/frontend/app/components/comment/getVoteMessage.ts new file mode 100644 index 00000000..18c82a77 --- /dev/null +++ b/frontend/app/components/comment/getVoteMessage.ts @@ -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]; +} diff --git a/frontend/app/components/root/root.tsx b/frontend/app/components/root/root.tsx index 066ec3b8..d44bc208 100644 --- a/frontend/app/components/root/root.tsx +++ b/frontend/app/components/root/root.tsx @@ -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 boundActions; +type Props = ReturnType & 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 { constructor(props: Props) { @@ -243,6 +251,7 @@ export class Root extends Component {
{!isGuest && !isCommentsDisabled && ( { )} {this.props.pinnedComments.length > 0 && ( -
+
{this.props.pinnedComments.map(comment => ( { {commentsShown < this.props.topComments.length && IS_MOBILE && ( )}
@@ -304,6 +318,7 @@ export class Root extends Component { {this.props.isSettingsVisible && (
{ )}

- Powered by{' '} - - Remark42 - + ( + + {title} + + ), + }} + />

@@ -332,5 +354,6 @@ export class Root extends Component { export const ConnectedRoot: FunctionComponent = () => { const props = useSelector(mapStateToProps); const actions = useActions(boundActions); - return ; + const intl = useIntl(); + return ; }; diff --git a/frontend/app/components/settings/settings.tsx b/frontend/app/components/settings/settings.tsx index 2f15563f..eaa015c3 100644 --- a/frontend/app/components/settings/settings.tsx +++ b/frontend/app/components/settings/settings.tsx @@ -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 { constructor(props: Props) { super(props); @@ -42,7 +63,7 @@ export default class Settings extends Component { } 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 { }; 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 { render({ user, theme }: Props, { blockedUsers, unblockedUsers, unhiddenUsers }: State) { const hiddenUsersList = Object.values(this.state.hiddenUsers); + const intl = this.props.intl; return (
-
-

Hidden users:

- {!hiddenUsersList.length &&

There are no hidden users.

} +
+

+ +

+ {!hiddenUsersList.length && ( +

+ +

+ )} {!!hiddenUsersList.length && (
    {hiddenUsersList.map(user => { @@ -92,15 +124,15 @@ export default class Settings extends Component { className={['settings__username', isUserUnhidden ? 'settings__invisible' : null].join(' ')} title={user.id} > - {user.name || 'unknown'} + {user.name ? user.name : } {this.__isUserHidden(user) ? ( this.unhide(user))}> - show + ) : ( this.hide(user))}> - hide + )}
    @@ -115,10 +147,20 @@ export default class Settings extends Component { )}
    {user && user.admin && ( -
    -

    Blocked users:

    +
    +

    + +

    - {!blockedUsers.length &&

    There are no blocked users.

    } + {!blockedUsers.length && ( +

    + +

    + )} {!!blockedUsers.length && (
      @@ -131,17 +173,20 @@ export default class Settings extends Component { className={['settings__username', isUserUnblocked ? 'settings__invisible' : null].join(' ')} title={user.id} > - {user.name || 'unknown'} + {user.name ? user.name : } + + + {' '} + - {formatTime(new Date(user.time))} {isUserUnblocked && ( this.block(user))} className="settings__action"> - block + )} {!isUserUnblocked && ( this.unblock(user))} className="settings__action"> - unblock + )}
      @@ -163,16 +208,20 @@ export default class Settings extends Component { 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 ; - // '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 ( + + ); } diff --git a/frontend/app/components/thread/thread.tsx b/frontend/app/components/thread/thread.tsx index 87ea86fd..b16f314e 100644 --- a/frontend/app/components/thread/thread.tsx +++ b/frontend/app/components/thread/thread.tsx @@ -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 = ({ 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 = ({ id, level, mix, getPreview }) ref={ref => inviewProps.ref(ref)} key={`comment-${id}`} view="main" + intl={intl} data={comment} repliesCount={repliesCount} level={level} diff --git a/frontend/app/components/user-info/last-comments-list.tsx b/frontend/app/components/user-info/last-comments-list.tsx index 5511413c..3a92e0a6 100644 --- a/frontend/app/components/user-info/last-comments-list.tsx +++ b/frontend/app/components/user-info/last-comments-list.tsx @@ -10,7 +10,6 @@ const LastCommentsList = ({ comments, isLoading }: { comments: CommentType[]; is if (isLoading) { return ; } - return (
      {comments.map(comment => ( diff --git a/frontend/app/components/user-info/user-info.tsx b/frontend/app/components/user-info/user-info.tsx index 68d298ae..1c6f6db6 100644 --- a/frontend/app/components/user-info/user-info.tsx +++ b/frontend/app/components/user-info/user-info.tsx @@ -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 { 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 { return (
      -

      Last comments by {user.name}

      +

      + +

      {user.id}

      {!!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 ; + const intl = useIntl(); + return ; }; diff --git a/frontend/app/last-comments.tsx b/frontend/app/last-comments.tsx index 9b27492d..5f0eace9 100644 --- a/frontend/app/last-comments.tsx +++ b/frontend/app/last-comments.tsx @@ -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 { (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(, node); + render( + + + , + node + ); } catch (e) { console.error('Remark42: Something went wrong with last comments rendering'); console.error(e); diff --git a/frontend/app/locales/de.json b/frontend/app/locales/de.json new file mode 100644 index 00000000..d2dddbc9 --- /dev/null +++ b/frontend/app/locales/de.json @@ -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 Markdown 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 Remark42", + "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 ", + "toolbar.code": "Insert a code", + "toolbar.header": "Add header text", + "toolbar.italic": "Add italic text ", + "toolbar.link": "Add a link ", + "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" +} diff --git a/frontend/app/locales/en.json b/frontend/app/locales/en.json new file mode 100644 index 00000000..d2dddbc9 --- /dev/null +++ b/frontend/app/locales/en.json @@ -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 Markdown 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 Remark42", + "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 ", + "toolbar.code": "Insert a code", + "toolbar.header": "Add header text", + "toolbar.italic": "Add italic text ", + "toolbar.link": "Add a link ", + "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" +} diff --git a/frontend/app/locales/ru.json b/frontend/app/locales/ru.json new file mode 100644 index 00000000..3fb79196 --- /dev/null +++ b/frontend/app/locales/ru.json @@ -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": "Поддерживается Markdown форматирование", + "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 Remark42", + "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": "Жирный ", + "toolbar.code": "Код", + "toolbar.header": "Заголовок", + "toolbar.italic": "Курсив ", + "toolbar.link": "Ссылка ", + "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": "Нельзя головать за комментарии, которые в режиме только для чтения" +} diff --git a/frontend/app/remark.tsx b/frontend/app/remark.tsx index 8228e62e..0e32d9fc 100644 --- a/frontend/app/remark.tsx +++ b/frontend/app/remark.tsx @@ -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 { } return memo; }, {}); - + const locale = getLocale(params); + const messages = await loadLocale(locale).catch(() => ({})); StaticStore.config = await api.getConfig(); if (params.page === 'user-info') { return render( -
      -
      - - - + +
      +
      + + + +
      -
      , + , node ); } render( - - - , + + + + + , node ); } diff --git a/frontend/app/utils/errorUtils.ts b/frontend/app/utils/errorUtils.ts index 3fec6972..c084ee5b 100644 --- a/frontend/app/utils/errorUtils.ts +++ b/frontend/app/utils/errorUtils.ts @@ -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(); + +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; diff --git a/frontend/app/utils/getLocale.ts b/frontend/app/utils/getLocale.ts new file mode 100644 index 00000000..b509c6b9 --- /dev/null +++ b/frontend/app/utils/getLocale.ts @@ -0,0 +1,4 @@ +import { LastCommentsConfig } from '@app/common/config-types'; +export function getLocale(params: { [key: string]: string } | LastCommentsConfig): string { + return params.locale || 'en'; +} diff --git a/frontend/app/utils/loadLocale.ts b/frontend/app/utils/loadLocale.ts new file mode 100644 index 00000000..04bba8c1 --- /dev/null +++ b/frontend/app/utils/loadLocale.ts @@ -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> { + 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; +} diff --git a/frontend/index.ejs b/frontend/index.ejs index 1950b09c..d2c1ee3e 100644 --- a/frontend/index.ejs +++ b/frontend/index.ejs @@ -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) { diff --git a/frontend/jest.config.js b/frontend/jest.config.js index e28caa1b..bff8bd35 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -15,4 +15,5 @@ module.exports = { '^react-dom$': 'preact/compat', }, setupFilesAfterEnv: ['/app/testUtils/index.ts'], + transformIgnorePatterns: ['/node_modules/(?!intl-messageformat|intl-messageformat-parser).+\\.js$'], }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 703b53f9..172afffb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 4746a9be..5791e564 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/tasks/generateDictionary.js b/frontend/tasks/generateDictionary.js new file mode 100644 index 00000000..0edd5153 --- /dev/null +++ b/frontend/tasks/generateDictionary.js @@ -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')) + ); +}); diff --git a/frontend/tasks/getSupportedLocales.js b/frontend/tasks/getSupportedLocales.js new file mode 100644 index 00000000..ec3b0acb --- /dev/null +++ b/frontend/tasks/getSupportedLocales.js @@ -0,0 +1,6 @@ +const getSupportedLocales = require('./supportedLocales'); +module.exports = { + getSupportedLocales() { + return getSupportedLocales; + }, +}; diff --git a/frontend/tasks/localeLoadTemplate.js b/frontend/tasks/localeLoadTemplate.js new file mode 100644 index 00000000..0bc1320d --- /dev/null +++ b/frontend/tasks/localeLoadTemplate.js @@ -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> { +${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 }; diff --git a/frontend/tasks/supportedLocales.json b/frontend/tasks/supportedLocales.json new file mode 100644 index 00000000..ed6daed5 --- /dev/null +++ b/frontend/tasks/supportedLocales.json @@ -0,0 +1 @@ +["en", "ru", "de"] diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a831df3c..46053048 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,6 +16,7 @@ "baseUrl": "./", "alwaysStrict": true, "sourceMap": true, + "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "paths": { "*": ["@types/*"], diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 6c73f7ee..3736d11e 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -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,