Commit efc9dc1d authored by Tasso Evangelista's avatar Tasso Evangelista Committed by Renato Becker

[FIX] i18n (#145)

* Disable automatic translation

* Update dependencies

* Add a11y addon into storybook

* Fix many markup errors

* Detect browser language and force update on language change

* Downgrade preact-cli to avoid hot reload bugs

* Add missing i18n strings

* Fix some route stories

* Import i18n strings from Rocket.Chat

* Get language from config first
parent ed8e3d32
import '@storybook/addon-a11y/register';
import '@storybook/addon-actions/register';
import '@storybook/addon-knobs/register';
import '@storybook/addon-options/register';
......
import { addDecorator, configure } from '@storybook/react';
import { checkA11y } from '@storybook/addon-a11y';
import { setConsoleOptions } from '@storybook/addon-console';
import { withOptions } from '@storybook/addon-options';
......@@ -10,6 +11,8 @@ addDecorator(withOptions({
hierarchyRootSeparator: /\|/,
}));
addDecorator(checkA11y);
setConsoleOptions({
panelExclude: [],
});
......
No preview for this file type
#!/usr/bin/env node
const fs = require('fs');
const prompts = require('prompts');
const { promisify } = require('util');
const importTranslationsFrom = async(rocketChatSourceDir) => {
const oldTranslations = (await promisify(fs.readdir)(`${ rocketChatSourceDir }/packages/rocketchat-i18n/i18n`))
.filter((name) => name.startsWith('livechat.') && name !== 'livechat.en.i18n.json')
.map((name) => ({
language: /livechat\.(.+?)\.i18n.json/.exec(name)[1].replace('-', '_'),
strings: require(`${ rocketChatSourceDir }/packages/rocketchat-i18n/i18n/${ name }`),
}));
const newStrings = require('./src/i18n/default.json').en;
const oldStrings = require(`${ rocketChatSourceDir }/packages/rocketchat-i18n/i18n/livechat.en.i18n.json`);
const mapKeys = {};
for (const [newKey, newString] of Object.entries(newStrings)) {
const oldEntry = Object.entries(oldStrings).find(([, oldString]) => newString === oldString);
oldEntry && (mapKeys[oldEntry[0]] = newKey);
}
const newTranslations = oldTranslations
.map(({ language, strings }) => ({
language,
strings: {
...newStrings,
...(
Object.entries(strings)
.filter(([oldKey]) => !!mapKeys[oldKey])
.reduce((strings, [oldKey, oldString]) => ({ ...strings, [mapKeys[oldKey]]: oldString }), {})
),
},
}));
for (const { language, strings } of newTranslations) {
console.log(`Writing i18n file for language "${ language }"...`);
await promisify(fs.writeFile)(`${ __dirname }/src/i18n/${ language }.json`, JSON.stringify(strings, null, 2));
}
};
const main = async() => {
const { rocketChatSourceDir } = await prompts({
type: 'text',
name: 'rocketChatSourceDir',
message: 'Where is Rocket.Chat source?',
initial: process.argv[2] || '../Rocket.Chat',
validate: (path) => {
try {
return fs.lstatSync(path).isDirectory();
} catch (e) {
return false;
}
},
});
await importTranslationsFrom(rocketChatSourceDir);
};
require.main === module && main();
......@@ -11,6 +11,7 @@ export const Alert = ({ children, success, warning, error, color, onDismiss, ...
<button
onClick={onDismiss}
className={createClassName(styles, 'alert__close')}
aria-label={I18n.t('Dismiss this alert')}
>
<CloseIcon width={20} />
</button>
......
......@@ -7,16 +7,6 @@ const alertText = 'A simple alert';
storiesOf('Components|Alert', module)
.addDecorator(withKnobs)
.add('simple', () => (
<Alert
success={boolean('success', false)}
warning={boolean('warning', false)}
error={boolean('error', false)}
onDismiss={action('clicked')}
>
{text('text', alertText)}
</Alert>
))
.add('success', () => (
<Alert
success={boolean('success', true)}
......
......@@ -114,7 +114,13 @@ export class App extends Component {
await dispatch({ visible: !visibility.hidden });
}
handleLanguageChange = () => {
this.forceUpdate();
}
async initialize() {
// TODO: split these behaviors into composable components
await Livechat.connect();
await loadConfig();
this.handleTriggers();
......@@ -127,22 +133,34 @@ export class App extends Component {
const { minimized } = this.props;
parentCall(minimized ? 'minimizeWindow' : 'restoreWindow');
visibility.addListener(this.handleVisibilityChange);
this.handleVisibilityChange();
const configLanguage = () => {
const { config: { settings: { language } = {} } = {} } = this.props;
return language;
};
const browserLanguage = () => (navigator.userLanguage || navigator.language);
I18n.changeLocale((configLanguage() || browserLanguage() || 'en').replace('-', '_'));
I18n.on('change', this.handleLanguageChange);
}
async finalize() {
CustomFields.reset();
userPresence.reset();
visibility.removeListener(this.handleVisibilityChange);
I18n.off('change', this.handleLanguageChange);
}
componentDidMount() {
this.initialize();
visibility.addListener(this.handleVisibilityChange);
this.handleVisibilityChange();
}
componentWillUnmount() {
this.finalize();
visibility.removeListener(this.handleVisibilityChange);
}
render = ({
......
......@@ -5,6 +5,7 @@ import CloseIcon from '../../icons/close.svg';
export const ChatButton = ({
text,
open = false,
badge,
className,
......@@ -17,6 +18,7 @@ export const ChatButton = ({
type="button"
className={createClassName(styles, 'chat-button', { open }, [className])}
style={(style || backgroundColor || color) ? { ...(style || {}), backgroundColor, color } : null}
aria-label={text}
>
{badge && <span className={createClassName(styles, 'chat-button__badge')}>{badge}</span>}
{open ? <CloseIcon /> : <ChatIcon />}
......
......@@ -16,6 +16,7 @@ storiesOf('Components|ChatButton', module)
color: color('theme.color', ''),
iconColor: color('theme.iconColor', ''),
}}
text={text('text', 'Open chat')}
onClick={action('clicked')}
/>
))
......@@ -27,6 +28,7 @@ storiesOf('Components|ChatButton', module)
color: color('theme.color', ''),
iconColor: color('theme.iconColor', ''),
}}
text={text('text', 'Open chat')}
onClick={action('clicked')}
/>
))
......@@ -38,6 +40,7 @@ storiesOf('Components|ChatButton', module)
color: color('theme.color', 'darkred'),
iconColor: color('theme.iconColor', 'peachpuff'),
}}
text={text('text', 'Open chat')}
onClick={action('clicked')}
/>
))
......@@ -49,6 +52,7 @@ storiesOf('Components|ChatButton', module)
color: color('theme.color', ''),
iconColor: color('theme.iconColor', ''),
}}
text={text('text', 'Close chat')}
onClick={action('clicked')}
/>
))
......
......@@ -9,15 +9,16 @@ const handleActionClick = (onClick) => (event) => {
onClick && onClick(event);
};
export const Actions = ({ children, ...args }) => (
<div className={createClassName(styles, 'composer__actions')} {...args}>{children}</div>
export const Actions = ({ children, ...props }) => (
<div className={createClassName(styles, 'composer__actions')} {...props}>{children}</div>
);
export const Action = ({ children, onClick, ...args }) => (
export const Action = ({ children, text, onClick, ...props }) => (
<button
onClick={handleActionClick(onClick)}
{...props}
className={createClassName(styles, 'composer__action')}
{...args}
aria-label={text}
onClick={handleActionClick(onClick)}
>{children}</button>
);
......
......@@ -72,17 +72,17 @@ storiesOf('Components|Composer', module)
onUpload={action('upload')}
pre={
<Actions>
<Action onClick={action('click smile')}>
<Action text="Add emoji" onClick={action('click smile')}>
<Smile width="20" />
</Action>
<Action onClick={action('click send')}>
<Action text="Send" onClick={action('click send')}>
<Send color="#1D74F5" width="20" />
</Action>
</Actions>
}
post={
<Actions>
<Action onClick={action('click plus')}>
<Action text="Add attachment" onClick={action('click plus')}>
<Plus width="20" />
</Action>
</Actions>
......
......@@ -6,6 +6,7 @@ import ChangeIcon from '../../icons/change.svg';
import RemoveIcon from '../../icons/remove.svg';
import FinishIcon from '../../icons/finish.svg';
export const Footer = ({ children, ...props }) => (
<footer className={createClassName(styles, 'footer')} {...props}>
{children}
......@@ -22,17 +23,18 @@ export const Content = ({ children, ...props }) => (
export const PoweredBy = (props) => (
<h3 className={createClassName(styles, 'powered-by')} {...props}>
Powered by
<a href="https://rocket.chat" target="_blank" rel="noopener noreferrer" translate="no">
<Logo title="Rocket.Chat" class={createClassName(styles, 'powered-by__logo')} width="60" />
{I18n.t('Powered by Rocket.Chat').split('Rocket.Chat')[0]}
<a href="https://rocket.chat" starget="_blank" rel="noopener noreferrer">
<Logo class={createClassName(styles, 'powered-by__logo')} width={60} role="img" aria-label="Rocket.Chat" />
</a>
{I18n.t('Powered by Rocket.Chat').split('Rocket.Chat')[1]}
</h3>
);
const OptionsTrigger = ({ pop }) => (
<button className={createClassName(styles, 'footer__options')} onClick={pop}>
Options
{I18n.t('Options')}
</button>
);
......
import md from './Markdown';
import Attachments from './Attachments';
import styles from './styles';
import md from './Markdown';
import Avatar from '../../components/Avatar';
import { createClassName, parseDate, parseMessage } from '../helpers';
import styles from './styles';
export const Body = ({ me, children, Element = 'div', group, className, ...args }) => (
<Element className={createClassName(styles, 'message', { me, group }, [className])} {...args}>
export const Body = ({ me, children, Element = 'div', group, className, ...props }) => (
<Element className={createClassName(styles, 'message', { me, group }, [className])} {...props}>
{children}
</Element>
);
export const Container = ({ children, className, ...args }) => (
<div {...args} className={createClassName(styles, 'message__container', {}, [className])}>{children}</div>
export const Container = ({ children, className, ...props }) => (
<div {...props} className={createClassName(styles, 'message__container', {}, [className])}>{children}</div>
);
export const Text = ({ children, me, className, ...args }) => (
<div {...args} className={createClassName(styles, 'message__text', { me }, [className])}>{children}</div>
export const Text = ({ children, me, className, ...props }) => (
<div {...props} className={createClassName(styles, 'message__text', { me }, [className])}>{children}</div>
);
export const Content = ({ children, me, className, ...args }) => (
<div {...args} className={createClassName(styles, 'message__content', { me }, [className])}>{children}</div>
export const Content = ({ children, me, className, ...props }) => (
<div {...props} className={createClassName(styles, 'message__content', { me }, [className])}>{children}</div>
);
const Message = ({ _id, el, msg, ts, me, group, avatarUrl, attachmentsUrl, className, ...args }) => (
const Message = ({
_id,
el,
msg,
ts,
me,
group,
avatar,
attachmentsUrl,
className,
...props
}) => (
<Body id={_id} me={me} group={group} Element={el} className={className}>
<Container>
{!me && <Avatar src={avatarUrl} className={createClassName(styles, 'avatar', { group })} />}
{!me && (
<Avatar
src={avatar && avatar.src}
description={avatar && avatar.description}
className={createClassName(styles, 'avatar', { group })}
/>
)}
<Content me={me}>
{msg && <Text me={me} dangerouslySetInnerHTML={{ __html: md.render(parseMessage(args, msg)) }} />}
{msg && <Text me={me} dangerouslySetInnerHTML={{ __html: md.render(parseMessage(props, msg)) }} />}
{attachmentsUrl && attachmentsUrl.length && <Attachments attachments={attachmentsUrl} />}
<div className={createClassName(styles, 'message__time', {})}>{parseDate(ts)}</div>
</Content>
{me && <Avatar src={avatarUrl} className={createClassName(styles, 'avatar', { group })} />}
{me && (
<Avatar
src={avatar && avatar.src}
description={avatar && avatar.description}
className={createClassName(styles, 'avatar', { group })}
/>
)}
</Container>
</Body>
);
......
import { storiesOf } from '@storybook/react';
import centered from '@storybook/addon-centered';
import { withKnobs } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import Message from '.';
......
import isSameDay from 'date-fns/is_same_day';
import Message from '../Message';
import TypingIndicator from '../TypingIndicator';
import Separator from '../Separator';
import { createClassName, getAttachmentsUrl } from '../helpers';
import TypingIndicator from '../TypingIndicator';
import { createClassName, flatMap, getAttachmentsUrl } from '../helpers';
import styles from './styles';
......@@ -15,31 +15,33 @@ export const Messages = ({
lastReadMessageId,
}) => (
<ol className={createClassName(styles, 'messages')}>
{messages.map((message, index, arr) => {
const previousMessage = arr[index - 1];
const nextMessage = arr[index + 1];
{flatMap(messages, (message, i) => {
const previousMessage = messages[i - 1];
const nextMessage = messages[i + 1];
const showDateSeparator = !previousMessage || !isSameDay(message.ts, previousMessage.ts);
const showUnreadSeparator = lastReadMessageId && nextMessage && lastReadMessageId === message._id;
return (
<div>
{showDateSeparator && <Separator date={message.ts} />}
<Message
el="li"
key={message._id}
me={user._id && user._id === message.u._id}
group={nextMessage && message.u._id === nextMessage.u._id}
avatarUrl={(user._id === message.u._id && user.avatar && user.avatar.src) ||
(agent._id === message.u._id && agent.avatar && agent.avatar.src)}
attachmentsUrl={getAttachmentsUrl(message.attachments)}
conversationFinishedMessage={conversationFinishedMessage}
{...message}
/>
{lastReadMessageId && nextMessage && lastReadMessageId === message._id && <Separator unread />}
</div>
);
})}
{typingAvatars && !!typingAvatars.length && <TypingIndicator avatars={typingAvatars} />}
return [
showDateSeparator && <Separator key={message.ts} el="li" date={message.ts} />,
<Message
el="li"
key={message._id}
me={user._id && user._id === message.u._id}
group={nextMessage && message.u._id === nextMessage.u._id}
avatar={{
src: (user._id === message.u._id && user.avatar && user.avatar.src) ||
(agent._id === message.u._id && agent.avatar && agent.avatar.src),
description: (user._id === message.u._id && user.avatar && user.avatar.description) ||
(agent._id === message.u._id && agent.avatar && agent.avatar.description),
}}
attachmentsUrl={getAttachmentsUrl(message.attachments)}
conversationFinishedMessage={conversationFinishedMessage}
{...message}
/>,
showUnreadSeparator && <Separator key="unread" el="li" unread />,
].filter(Boolean);
}, [])}
{typingAvatars && !!typingAvatars.length && <TypingIndicator el="li" avatars={typingAvatars} />}
</ol>
);
......
......@@ -67,8 +67,8 @@ export const ModalMessage = ({ children }) => (
export const ConfirmationModal = ({
text,
confirmButtonText = 'Yes',
cancelButtonText = 'No',
confirmButtonText = I18n.t('Yes'),
cancelButtonText = I18n.t('No'),
onConfirm,
onCancel,
...props
......@@ -83,7 +83,7 @@ export const ConfirmationModal = ({
);
export const AlertModal = ({ text, buttonText = 'Ok', onConfirm, ...props }) => (
export const AlertModal = ({ text, buttonText = I18n.t('OK'), onConfirm, ...props }) => (
<Modal open animated dismissByOverlay={false} {...props}>
<Modal.Message>{text}</Modal.Message>
<Button.Group>
......
......@@ -81,12 +81,11 @@ class ScreenHeader extends Component {
</Header.Action>
</Tooltip.Trigger>
{(expanded || !windowed) && (
<Tooltip.Trigger content={I18n.t('Minimize chat')}>
<Tooltip.Trigger content={minimized ? I18n.t('Restore chat') : I18n.t('Minimize chat')}>
<Header.Action
aria-label={minimized ? I18n.t('Restore') : I18n.t('Minimize')}
aria-label={minimized ? I18n.t('Restore chat') : I18n.t('Minimize chat')}
onClick={minimized ? onRestore : onMinimize}
>
{minimized ?
<RestoreIcon width={20} /> :
<MinimizeIcon width={20} />
......@@ -96,7 +95,7 @@ class ScreenHeader extends Component {
)}
{(!expanded && !windowed) && (
<Tooltip.Trigger content={I18n.t('Expand chat')} placement="bottom-left">
<Header.Action aria-label={I18n.t('Open in a new window')} onClick={onOpenWindow}>
<Header.Action aria-label={I18n.t('Expand chat')} onClick={onOpenWindow}>
<OpenWindowIcon width={20} />
</Header.Action>
</Tooltip.Trigger>
......@@ -257,6 +256,7 @@ export const Screen = ({
/>
<ChatButton
text={title}
open={!minimized}
onClick={minimized ? onRestore : onMinimize}
className={createClassName(styles, 'screen__chat-button')}
......
......@@ -3,19 +3,21 @@ import styles from './styles';
import { createClassName } from '../helpers';
const Separator = ({ date, unread }) => {
const Separator = ({ date, unread, el = 'div' }) => {
if (!date && !unread) {
return null;
}
const Element = el;
return (
<div class={createClassName(styles, 'separator', { date, unread })}>
<hr class={createClassName(styles, 'separator__line')} />
<span class={createClassName(styles, 'separator__text')}>
<Element className={createClassName(styles, 'separator', { date, unread })}>
<hr className={createClassName(styles, 'separator__line')} />
<span className={createClassName(styles, 'separator__text')}>
{date ? format(date, 'MMM DD, YYYY').toUpperCase() : I18n.t('unread messages')}
</span>
<hr class={createClassName(styles, 'separator__line')} />
</div>
<hr className={createClassName(styles, 'separator__line')} />
</Element>
);
};
......
......@@ -50,7 +50,7 @@ storiesOf('Components|Separator', module)
unread={boolean('unread', true)}
/>
<Message me Element="div" {...{
_id: '9NSuEkEttArE2Ny6Q',
_id: '9NSuEkEttArE2Ny6R',
rid: 'oQAGfG32u3uGptekbyhHvK7uhhXh9DqKWH',
msg: 'Could you send me a print screen? http://open.rocket.chat/logo.png',
ts: new Date(),
......
......@@ -20,8 +20,8 @@ const TypingAvatar = ({ avatars = [] }) => (
);
export const TypingIndicator = ({ avatars = [], children }) => (
<Message.Body>
export const TypingIndicator = ({ avatars = [], children, el }) => (
<Message.Body Element={el}>
<Message.Container>
<TypingAvatar avatars={avatars} />
<Message.Content>
......
{
"af": {
"are_you_sure_you_want_to_switch_the_department_d50a0b08": "Are you sure you want to switch the department?",
"cancel_caeb1e68": "Cancel",
"change_department_1d671538": "Change department",
"change_department_523a16e8": "Change Department",
"chat_finished_effbd589": "Chat Finished",
"choose_a_department_b106da55": "Choose a department...",
"choose_a_department_fe9755fd": "Choose a department",
"choose_an_option_26ac97d2": "Choose an option...",
"connecting_to_an_agent_5d1a34c4": "Connecting to an agent...",
"conversation_finished_6a0f2811": "Conversation finished",
"count_new_messages_since_since_47c9d2a0": {
"one": "One new message since %{since}",
"other": "%{count} new messages since %{since}"
},
"department_switched_cff305cf": "Department switched",
"departments_3826b025": "Departments",
"disable_notifications_dd6a3180": "Disable notifications",
"dismiss_this_alert_ea9b3104": "Dismiss this alert",
"email_22a7d52d": "Email",
"enable_notifications_a3daf4b1": "Enable notifications",
"expand_chat_a0045dbd": "Expand chat",
"file_exceeds_allowed_size_of_size_bd65c389": "File exceeds allowed size of %{size}.",
"fileupload_error_9eedee68": "FileUpload Error",
"finish_this_chat_87b79542": "Finish this chat",
"forget_remove_my_personal_data_309a5206": "Forget/Remove my personal data",
"gdpr_8b366c2b": "GDPR",
"go_to_menu_options_forget_remove_my_personal_data__99c40934": "Go to **menu options → Forget/Remove my personal data** to request the immediate removal of your data.",
"i_need_help_with_61054e21": "I need help with...",
"if_you_have_any_other_questions_just_press_the_but_ceaadfa0": "If you have any other questions, just press the button below to start a new chat.",
"insert_your_email_here_2e37fc94": "Insert your email here...",
"insert_your_name_here_3a8f5f46": "Insert your name here...",
"leave_a_message_5b581048": "Leave a message",
"media_types_not_accepted_4e25676a": "Media Types Not Accepted.",
"message_5c38209d": "Message",
"minimize_chat_804b3135": "Minimize chat",
"name_1aed4a1b": "Name",
"need_help_803a61": "Need help?",
"no_available_agents_to_transfer_3ae30cec": "No available agents to transfer",
"no_e16d9132": "No",
"ok_c47544a2": "OK",
"options_3ab0ea65": "Options",
"please_tell_us_some_informations_to_start_the_chat_7d6b71de": "Please, tell us some informations to start the chat",