diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bdcfe987..eb2256e684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Resolves [#2539](https://github.com/Microsoft/BotFramework-WebChat/issues/2539), added React hooks for customziation, by [@compulim](https://github.com/compulim) and [@corinagum](https://github.com/corinagum), in the following PRs: - PR [#2540](https://github.com/microsoft/BotFramework-WebChat/pull/2540): `useActivities`, `useReferenceGrammarID`, `useSendBoxDictationStarted` - PR [#2541](https://github.com/microsoft/BotFramework-WebChat/pull/2541): `useStyleOptions`, `useStyleSet` + - PR [#2542](https://github.com/microsoft/BotFramework-WebChat/pull/2542): `useLanguage`, `useLocalize`, `useLocalizeDate` ### Fixed diff --git a/__tests__/hooks/useActivity.js b/__tests__/hooks/useActivity.js index 56bbf23e12..764f9fe83e 100644 --- a/__tests__/hooks/useActivity.js +++ b/__tests__/hooks/useActivity.js @@ -47,8 +47,9 @@ test('should return list of activities', async () => { `); }); -test('setter should throw exception', async () => { +test('setter should be falsy', async () => { const { pageObjects } = await setupWebDriver(); + const [_, setActivities] = await pageObjects.runHook('useActivities'); - await expect(pageObjects.runHook('useActivities', [], result => result[1]())).rejects.toThrow(); + expect(setActivities).toBeFalsy(); }); diff --git a/__tests__/hooks/useLanguage.js b/__tests__/hooks/useLanguage.js new file mode 100644 index 0000000000..1253b77f7b --- /dev/null +++ b/__tests__/hooks/useLanguage.js @@ -0,0 +1,33 @@ +import { timeouts } from '../constants.json'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +test('getter should return language set in props', async () => { + const { pageObjects } = await setupWebDriver({ + props: { + locale: 'zh-YUE' + } + }); + + const [groupTimestamp] = await pageObjects.runHook('useLanguage'); + + expect(groupTimestamp).toMatchInlineSnapshot(`"zh-YUE"`); +}); + +test('getter should return default language if not set in props', async () => { + const { pageObjects } = await setupWebDriver(); + + const [groupTimestamp] = await pageObjects.runHook('useLanguage'); + + expect(groupTimestamp).toMatchInlineSnapshot(`"en-US"`); +}); + +test('setter should be undefined', async () => { + const { pageObjects } = await setupWebDriver(); + const [_, setLanguage] = await pageObjects.runHook('useLanguage'); + + expect(setLanguage).toBeUndefined(); +}); diff --git a/__tests__/hooks/useLocalize.js b/__tests__/hooks/useLocalize.js new file mode 100644 index 0000000000..feca06604d --- /dev/null +++ b/__tests__/hooks/useLocalize.js @@ -0,0 +1,18 @@ +import { timeouts } from '../constants.json'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +test('calling localize should return a localized string', async () => { + const { pageObjects } = await setupWebDriver(); + + await expect(pageObjects.runHook('useLocalize', ['Chat'])).resolves.toMatchInlineSnapshot(`"Chat"`); +}); + +test('calling localize on zh-YUE should return a localized string', async () => { + const { pageObjects } = await setupWebDriver({ props: { locale: 'zh-YUE' } }); + + await expect(pageObjects.runHook('useLocalize', ['Chat'])).resolves.toMatchInlineSnapshot(`"傾偈"`); +}); diff --git a/__tests__/hooks/useReferenceGrammarId.js b/__tests__/hooks/useReferenceGrammarId.js index 9264278283..ac534329b8 100644 --- a/__tests__/hooks/useReferenceGrammarId.js +++ b/__tests__/hooks/useReferenceGrammarId.js @@ -25,8 +25,9 @@ test('getter should return reference grammar ID', async () => { expect(referenceGrammarID).toBe('12345678-1234-5678-abcd-12345678abcd'); }); -test('setter should throw exception', async () => { +test('setter should be falsy', async () => { const { pageObjects } = await setupWebDriver(); + const [_, setReferenceGrammarID] = await pageObjects.runHook('useReferenceGrammarID'); - await expect(pageObjects.runHook('useReferenceGrammarID', [], result => result[1]())).rejects.toThrow(); + expect(setReferenceGrammarID).toBeFalsy(); }); diff --git a/__tests__/hooks/useStyleOptions.js b/__tests__/hooks/useStyleOptions.js index f3d48c03ab..9b49309070 100644 --- a/__tests__/hooks/useStyleOptions.js +++ b/__tests__/hooks/useStyleOptions.js @@ -14,8 +14,9 @@ test('getter should get styleOptions from props', async () => { ); }); -test('setter should throw exception', async () => { +test('setter should be falsy', async () => { const { pageObjects } = await setupWebDriver(); + const [_, setStyleOptions] = await pageObjects.runHook('useStyleOptions'); - await expect(pageObjects.runHook('useStyleOptions', [], result => result[1]())).rejects.toThrow(); + expect(setStyleOptions).toBeFalsy(); }); diff --git a/__tests__/hooks/useStyleSet.js b/__tests__/hooks/useStyleSet.js index 3fc2adc0d3..8241444b55 100644 --- a/__tests__/hooks/useStyleSet.js +++ b/__tests__/hooks/useStyleSet.js @@ -20,8 +20,9 @@ test('getter should get styleSet from props', async () => { `); }); -test('setter should throw exception', async () => { +test('setter should be falsy', async () => { const { pageObjects } = await setupWebDriver(); + const [_, setStyleSet] = await pageObjects.runHook('useStyleSet'); - await expect(pageObjects.runHook('useStyleSet', [], result => result[1]())).rejects.toThrow(); + expect(setStyleSet).toBeFalsy(); }); diff --git a/__tests__/setup/web/index.html b/__tests__/setup/web/index.html index 1d8a87aec9..da3b221c8a 100644 --- a/__tests__/setup/web/index.html +++ b/__tests__/setup/web/index.html @@ -159,14 +159,25 @@ const activityMiddleware = () => next => card => { const { activity } = card; + if (activity.type === 'event' && activity.name === 'hook') { return children => React.createElement(() => { const { args, name, reject, resolve } = activity.value; + try { - resolve(window.WebChat.hooks[name](args)); + const hookFn = window.WebChat.hooks[name]; + + if (!hookFn) { + console.log(`No hooks named "${ name }" were found. Valid hooks are:`, Object.keys(window.WebChat.hooks).sort()); + + throw new Error(`No hooks named "${ name }" were found.`); + } + + resolve(hookFn(args)); } catch (err) { reject(err); } + return false; }); } diff --git a/__tests__/video.js b/__tests__/video.js index ab5b5626c0..6e81ebad80 100644 --- a/__tests__/video.js +++ b/__tests__/video.js @@ -43,12 +43,16 @@ test('video', async () => { .sendKeys('j') .perform(); - // Hide the spinner animation - await driver.executeScript(() => document.querySelector('.ytp-spinner').remove()); - // Wait for YouTube play/pause/rewind animation to complete await driver.sleep(1000); + // Hide the spinner animation + await driver.executeScript(() => { + const spinner = document.querySelector('.ytp-spinner'); + + spinner && spinner.remove(); + }); + const base64PNG = await driver.takeScreenshot(); expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js index f98e9cf969..625ffd92a9 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js @@ -4,10 +4,10 @@ import { HostConfig } from 'adaptivecards'; import PropTypes from 'prop-types'; import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { Components, connectToWebChat, getTabIndex, hooks, localize } from 'botframework-webchat-component'; +import { Components, connectToWebChat, getTabIndex, hooks } from 'botframework-webchat-component'; const { ErrorBox } = Components; -const { useStyleSet } = hooks; +const { useLocalize, useStyleSet } = hooks; function isPlainObject(obj) { return Object.getPrototypeOf(obj) === Object.prototype; @@ -66,12 +66,13 @@ const AdaptiveCardRenderer = ({ adaptiveCard, adaptiveCardHostConfig, disabled, - language, performCardAction, renderMarkdown, tapAction }) => { const [{ adaptiveCardRenderer: adaptiveCardRendererStyleSet }] = useStyleSet(); + const errorMessage = useLocalize('Adaptive Card render error'); + const [error, setError] = useState(); const contentRef = useRef(); const inputValuesRef = useRef([]); @@ -196,7 +197,7 @@ const AdaptiveCardRenderer = ({ }, [adaptiveCard, adaptiveCardHostConfig, contentRef, disabled, error, handleExecuteAction, renderMarkdown]); return error ? ( - +
{JSON.stringify(error, null, 2)}
) : ( @@ -208,7 +209,6 @@ AdaptiveCardRenderer.propTypes = { adaptiveCard: PropTypes.any.isRequired, adaptiveCardHostConfig: PropTypes.any.isRequired, disabled: PropTypes.bool, - language: PropTypes.string.isRequired, performCardAction: PropTypes.func.isRequired, renderMarkdown: PropTypes.func.isRequired, tapAction: PropTypes.shape({ @@ -222,9 +222,8 @@ AdaptiveCardRenderer.defaultProps = { tapAction: undefined }; -export default connectToWebChat(({ disabled, language, onCardAction, renderMarkdown, tapAction }) => ({ +export default connectToWebChat(({ disabled, onCardAction, renderMarkdown, tapAction }) => ({ disabled, - language, performCardAction: onCardAction, renderMarkdown, tapAction diff --git a/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardAttachment.js b/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardAttachment.js index 9b1ef18fa3..b7dc226e97 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardAttachment.js +++ b/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardAttachment.js @@ -1,20 +1,24 @@ /* eslint no-magic-numbers: ["error", { "ignore": [0, 1, 10, 15, 25, 75] }] */ -import { connectToWebChat, hooks, localize } from 'botframework-webchat-component'; +import { hooks } from 'botframework-webchat-component'; import PropTypes from 'prop-types'; import React, { useMemo } from 'react'; import AdaptiveCardBuilder from './AdaptiveCardBuilder'; import AdaptiveCardRenderer from './AdaptiveCardRenderer'; -const { useStyleOptions } = hooks; +const { useLocalize, useStyleOptions } = hooks; function nullOrUndefined(obj) { return obj === null || typeof obj === 'undefined'; } -const ReceiptCardAttachment = ({ adaptiveCardHostConfig, adaptiveCards, attachment: { content }, language }) => { +const ReceiptCardAttachment = ({ adaptiveCardHostConfig, adaptiveCards, attachment: { content } }) => { const [styleOptions] = useStyleOptions(); + const taxText = useLocalize('Tax'); + const totalText = useLocalize('Total'); + const vatText = useLocalize('VAT'); + const builtCard = useMemo(() => { const builder = new AdaptiveCardBuilder(adaptiveCards, styleOptions); const { HorizontalAlignment, TextSize, TextWeight } = adaptiveCards; @@ -64,33 +68,21 @@ const ReceiptCardAttachment = ({ adaptiveCardHostConfig, adaptiveCards, attachme if (!nullOrUndefined(vat)) { const vatCol = builder.addColumnSet([75, 25]); - builder.addTextBlock( - localize('VAT', language), - { size: TextSize.Medium, weight: TextWeight.Bolder }, - vatCol[0] - ); + builder.addTextBlock(vatText, { size: TextSize.Medium, weight: TextWeight.Bolder }, vatCol[0]); builder.addTextBlock(vat, { horizontalAlignment: HorizontalAlignment.Right }, vatCol[1]); } if (!nullOrUndefined(tax)) { const taxCol = builder.addColumnSet([75, 25]); - builder.addTextBlock( - localize('Tax', language), - { size: TextSize.Medium, weight: TextWeight.Bolder }, - taxCol[0] - ); + builder.addTextBlock(taxText, { size: TextSize.Medium, weight: TextWeight.Bolder }, taxCol[0]); builder.addTextBlock(tax, { horizontalAlignment: HorizontalAlignment.Right }, taxCol[1]); } if (!nullOrUndefined(total)) { const totalCol = builder.addColumnSet([75, 25]); - builder.addTextBlock( - localize('Total', language), - { size: TextSize.Medium, weight: TextWeight.Bolder }, - totalCol[0] - ); + builder.addTextBlock(totalText, { size: TextSize.Medium, weight: TextWeight.Bolder }, totalCol[0]); builder.addTextBlock( total, { horizontalAlignment: HorizontalAlignment.Right, size: TextSize.Medium, weight: TextWeight.Bolder }, @@ -102,7 +94,7 @@ const ReceiptCardAttachment = ({ adaptiveCardHostConfig, adaptiveCards, attachme return builder.card; } - }, [adaptiveCards, content, language, styleOptions]); + }, [adaptiveCards, content, styleOptions, taxText, totalText, vatText]); return ( ({ language }))(ReceiptCardAttachment); +export default ReceiptCardAttachment; diff --git a/packages/component/src/Activity/CarouselFilmStrip.js b/packages/component/src/Activity/CarouselFilmStrip.js index 2f2115cca7..afcb634a02 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.js +++ b/packages/component/src/Activity/CarouselFilmStrip.js @@ -8,7 +8,6 @@ import React from 'react'; import { Constants } from 'botframework-webchat-core'; -import { localize } from '../Localization/Localize'; import Avatar from './Avatar'; import Bubble from './Bubble'; import connectToWebChat from '../connectToWebChat'; @@ -16,6 +15,7 @@ import ScreenReaderText from '../ScreenReaderText'; import SendStatus from './SendStatus'; import textFormatToContentType from '../Utils/textFormatToContentType'; import Timestamp from './Timestamp'; +import useLocalize from '../hooks/useLocalize'; import useStyleOptions from '../hooks/useStyleOptions'; import useStyleSet from '../hooks/useStyleSet'; @@ -92,13 +92,15 @@ const WebChatCarouselFilmStrip = ({ children, className, itemContainerRef, - language, scrollableRef, timestampClassName }) => { const [{ bubbleNubSize, bubbleFromUserNubSize }] = useStyleOptions(); const [{ carouselFilmStrip: carouselFilmStripStyleSet }] = useStyleSet(); + const botRoleLabel = useLocalize('BotSent'); + const userRoleLabel = useLocalize('UserSent'); + const { attachments = [], channelData: { messageBack: { displayText: messageBackDisplayText } = {}, state } = {}, @@ -111,6 +113,8 @@ const WebChatCarouselFilmStrip = ({ const activityDisplayText = messageBackDisplayText || text; const indented = fromUser ? bubbleFromUserNubSize : bubbleNubSize; + const roleLabel = fromUser ? userRoleLabel : botRoleLabel; + return (
{!!activityDisplayText && (
- + {children({ activity, @@ -138,7 +142,7 @@ const WebChatCarouselFilmStrip = ({
    {attachments.map((attachment, index) => (
  • - + {children({ attachment })} @@ -184,14 +188,12 @@ WebChatCarouselFilmStrip.propTypes = { children: PropTypes.any, className: PropTypes.string, itemContainerRef: PropTypes.any.isRequired, - language: PropTypes.string.isRequired, scrollableRef: PropTypes.any.isRequired, timestampClassName: PropTypes.string }; -const ConnectedCarouselFilmStrip = connectCarouselFilmStrip(({ avatarInitials, language }) => ({ - avatarInitials, - language +const ConnectedCarouselFilmStrip = connectCarouselFilmStrip(({ avatarInitials }) => ({ + avatarInitials }))(WebChatCarouselFilmStrip); const CarouselFilmStrip = props => ( diff --git a/packages/component/src/Activity/CarouselLayout.js b/packages/component/src/Activity/CarouselLayout.js index e70414b9e0..ec95400ad4 100644 --- a/packages/component/src/Activity/CarouselLayout.js +++ b/packages/component/src/Activity/CarouselLayout.js @@ -4,9 +4,8 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import { localize } from '../Localization/Localize'; -import connectToWebChat from '../connectToWebChat'; import CarouselFilmStrip from './CarouselFilmStrip'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; const ROOT_CSS = css({ @@ -14,8 +13,10 @@ const ROOT_CSS = css({ position: 'relative' }); -const CarouselLayout = ({ activity, children, language, timestampClassName }) => { +const CarouselLayout = ({ activity, children, timestampClassName }) => { const [{ carouselFlipper: carouselFlipperStyleSet }] = useStyleSet(); + const leftLabel = useLocalize('Left'); + const rightLabel = useLocalize('Right'); const filmStyleSet = createBasicStyleSet({ cursor: null }); @@ -30,14 +31,14 @@ const CarouselLayout = ({ activity, children, language, timestampClassName }) => {scrollBarWidth !== '100%' && (
    {'<'}
    @@ -60,8 +61,7 @@ CarouselLayout.defaultProps = { CarouselLayout.propTypes = { activity: PropTypes.any.isRequired, children: PropTypes.any, - language: PropTypes.string.isRequired, timestampClassName: PropTypes.string }; -export default connectToWebChat(({ language }) => ({ language }))(CarouselLayout); +export default CarouselLayout; diff --git a/packages/component/src/Activity/SendStatus.js b/packages/component/src/Activity/SendStatus.js index 691ca96d1e..2c1d948a85 100644 --- a/packages/component/src/Activity/SendStatus.js +++ b/packages/component/src/Activity/SendStatus.js @@ -2,9 +2,9 @@ import { Constants } from 'botframework-webchat-core'; import PropTypes from 'prop-types'; import React from 'react'; -import { localize } from '../Localization/Localize'; import connectToWebChat from '../connectToWebChat'; import ScreenReaderText from '../ScreenReaderText'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; const { @@ -28,14 +28,16 @@ const connectSendStatus = (...selectors) => ...selectors ); -const SendStatus = ({ activity: { channelData: { state } = {} }, language, retrySend }) => { +const SendStatus = ({ activity: { channelData: { state } = {} }, retrySend }) => { const [{ sendStatus: sendStatusStyleSet }] = useStyleSet(); // TODO: [P4] Currently, this is the only place which use a templated string // We could refactor this into a general component if there are more templated strings - const localizedSending = localize('Sending', language); - const localizedSendStatus = localize('SendStatus', language); - const sendFailedText = localize('SEND_FAILED_KEY', language); + const localizedSending = useLocalize('Sending'); + const localizedSendStatus = useLocalize('SendStatus'); + const retryText = useLocalize('Retry'); + const sendFailedText = useLocalize('SEND_FAILED_KEY'); + const sendFailedRetryMatch = /\{Retry\}/u.exec(sendFailedText); return ( @@ -49,7 +51,7 @@ const SendStatus = ({ activity: { channelData: { state } = {} }, language, retry {sendFailedText.substr(0, sendFailedRetryMatch.index)} {sendFailedText.substr(sendFailedRetryMatch.index + sendFailedRetryMatch[0].length)} @@ -72,7 +74,6 @@ SendStatus.propTypes = { state: PropTypes.string }) }).isRequired, - language: PropTypes.string.isRequired, retrySend: PropTypes.func.isRequired }; diff --git a/packages/component/src/Activity/StackedLayout.js b/packages/component/src/Activity/StackedLayout.js index 51156939d4..3227ae0ea8 100644 --- a/packages/component/src/Activity/StackedLayout.js +++ b/packages/component/src/Activity/StackedLayout.js @@ -9,7 +9,6 @@ import React from 'react'; import remark from 'remark'; import stripMarkdown from 'strip-markdown'; -import { localize } from '../Localization/Localize'; import Avatar from './Avatar'; import Bubble from './Bubble'; import connectToWebChat from '../connectToWebChat'; @@ -17,6 +16,7 @@ import ScreenReaderText from '../ScreenReaderText'; import SendStatus from './SendStatus'; import textFormatToContentType from '../Utils/textFormatToContentType'; import Timestamp from './Timestamp'; +import useLocalize from '../hooks/useLocalize'; import useStyleOptions from '../hooks/useStyleOptions'; import useStyleSet from '../hooks/useStyleSet'; @@ -84,7 +84,7 @@ const connectStackedLayout = (...selectors) => ...selectors ); -const StackedLayout = ({ activity, avatarInitials, children, language, timestampClassName }) => { +const StackedLayout = ({ activity, avatarInitials, children, timestampClassName }) => { const [{ botAvatarInitials, bubbleNubSize, bubbleFromUserNubSize, userAvatarInitials }] = useStyleOptions(); const [{ stackedLayout: stackedLayoutStyleSet }] = useStyleSet(); @@ -102,14 +102,18 @@ const StackedLayout = ({ activity, avatarInitials, children, language, timestamp const plainText = remark() .use(stripMarkdown) .processSync(text); - const ariaLabel = localize( - fromUser ? 'User said something' : 'Bot said something', - language, - avatarInitials, - plainText - ); const indented = fromUser ? bubbleFromUserNubSize : bubbleNubSize; + const botRoleLabel = useLocalize('BotSent'); + const userRoleLabel = useLocalize('UserSent'); + + const roleLabel = fromUser ? botRoleLabel : userRoleLabel; + + const botAriaLabel = useLocalize('Bot said something', avatarInitials, plainText); + const userAriaLabel = useLocalize('User said something', avatarInitials, plainText); + + const ariaLabel = fromUser ? userAriaLabel : botAriaLabel; + return (
    - + {children({ attachment })} @@ -187,13 +191,11 @@ StackedLayout.propTypes = { }).isRequired, avatarInitials: PropTypes.string.isRequired, children: PropTypes.any, - language: PropTypes.string.isRequired, timestampClassName: PropTypes.string }; -export default connectStackedLayout(({ avatarInitials, language }) => ({ - avatarInitials, - language +export default connectStackedLayout(({ avatarInitials }) => ({ + avatarInitials }))(StackedLayout); export { connectStackedLayout }; diff --git a/packages/component/src/Attachment/DownloadAttachment.js b/packages/component/src/Attachment/DownloadAttachment.js index cc78ed73f1..737f8a9547 100644 --- a/packages/component/src/Attachment/DownloadAttachment.js +++ b/packages/component/src/Attachment/DownloadAttachment.js @@ -2,30 +2,29 @@ import { format } from 'bytes'; import PropTypes from 'prop-types'; import React from 'react'; -import { localize } from '../Localization/Localize'; -import connectToWebChat from '../connectToWebChat'; import DownloadIcon from './Assets/DownloadIcon'; import ScreenReaderText from '../ScreenReaderText'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; const DownloadAttachment = ({ activity: { attachments = [], channelData: { attachmentSizes = [] } = {} } = {}, - attachment, - language + attachment }) => { const [{ downloadAttachment: downloadAttachmentStyleSet }] = useStyleSet(); + const downloadLabel = useLocalize('Download file'); const attachmentIndex = attachments.indexOf(attachment); - const downloadLabel = localize('Download file', language); const size = attachmentSizes[attachmentIndex]; const formattedSize = typeof size === 'number' && format(size); - const downloadFileWithFileSizeLabel = localize( + + const downloadFileWithFileSizeLabel = useLocalize( 'DownloadFileWithFileSize', - language, downloadLabel, attachment.name, formattedSize ); + return ( @@ -53,8 +52,7 @@ DownloadAttachment.propTypes = { attachment: PropTypes.shape({ contentUrl: PropTypes.string.isRequired, name: PropTypes.string.isRequired - }).isRequired, - language: PropTypes.string.isRequired + }).isRequired }; -export default connectToWebChat(({ language }) => ({ language }))(DownloadAttachment); +export default DownloadAttachment; diff --git a/packages/component/src/Attachment/UploadAttachment.js b/packages/component/src/Attachment/UploadAttachment.js index 99807f15a7..8bdf6ea7f8 100644 --- a/packages/component/src/Attachment/UploadAttachment.js +++ b/packages/component/src/Attachment/UploadAttachment.js @@ -4,9 +4,8 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import { localize } from '../Localization/Localize'; -import connectToWebChat from '../connectToWebChat'; import ScreenReaderText from '../ScreenReaderText'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; const ROOT_CSS = css({ @@ -16,15 +15,14 @@ const ROOT_CSS = css({ const UploadAttachment = ({ activity: { attachments = [], channelData: { attachmentSizes = [] } = {} } = {}, - attachment, - language + attachment }) => { const [{ uploadAttachment: uploadAttachmentStyleSet }] = useStyleSet(); const attachmentIndex = attachments.indexOf(attachment); const size = attachmentSizes[attachmentIndex]; const formattedSize = typeof size === 'number' && format(size); - const uploadFileWithFileSizeLabel = localize('UploadFileWithFileSize', language, attachment.name, formattedSize); + const uploadFileWithFileSizeLabel = useLocalize('UploadFileWithFileSize', attachment.name, formattedSize); return ( @@ -46,8 +44,7 @@ UploadAttachment.propTypes = { }).isRequired, attachment: PropTypes.shape({ name: PropTypes.string.isRequired - }).isRequired, - language: PropTypes.string.isRequired + }).isRequired }; -export default connectToWebChat(({ language }) => ({ language }))(UploadAttachment); +export default UploadAttachment; diff --git a/packages/component/src/Composer.js b/packages/component/src/Composer.js index 44a442fef3..1d3ed6b37a 100644 --- a/packages/component/src/Composer.js +++ b/packages/component/src/Composer.js @@ -250,8 +250,8 @@ const Composer = ({ // 2. Filter out profanity // TODO: [P4] Revisit all members of context - // This context should have all stuff that is not in the Redux store - // That means, stuff that are not interested in other type of UIs + // This context should consist of members that are not in the Redux store + // i.e. members that are not interested in other types of UIs const context = useMemo( () => ({ ...cardActionContext, diff --git a/packages/component/src/Dictation.js b/packages/component/src/Dictation.js index 8b7f9336ef..fc51a8f310 100644 --- a/packages/component/src/Dictation.js +++ b/packages/component/src/Dictation.js @@ -6,6 +6,7 @@ import React, { useCallback, useMemo } from 'react'; import connectToWebChat from './connectToWebChat'; import useActivities from './hooks/useActivities'; +import useLanguage from './hooks/useLanguage'; const { DictateState: { DICTATING, IDLE, STARTING } @@ -15,7 +16,6 @@ const Dictation = ({ dictateState, disabled, emitTypingIndicator, - language, onError, sendTypingIndicator, setDictateInterims, @@ -27,6 +27,7 @@ const Dictation = ({ webSpeechPonyfill: { SpeechGrammarList, SpeechRecognition } = {} }) => { const [activities] = useActivities(); + const [language] = useLanguage(); const numSpeakingActivities = useMemo(() => activities.filter(({ channelData: { speak } = {} }) => speak).length, [ activities @@ -92,7 +93,6 @@ Dictation.propTypes = { dictateState: PropTypes.number.isRequired, disabled: PropTypes.bool, emitTypingIndicator: PropTypes.func.isRequired, - language: PropTypes.string.isRequired, onError: PropTypes.func, sendTypingIndicator: PropTypes.bool.isRequired, setDictateInterims: PropTypes.func.isRequired, @@ -112,7 +112,6 @@ export default connectToWebChat( dictateState, disabled, emitTypingIndicator, - language, postActivity, sendTypingIndicator, setDictateInterims, @@ -126,7 +125,6 @@ export default connectToWebChat( dictateState, disabled, emitTypingIndicator, - language, postActivity, sendTypingIndicator, setDictateInterims, diff --git a/packages/component/src/ErrorBox.js b/packages/component/src/ErrorBox.js index ca471af000..afc0eefeda 100644 --- a/packages/component/src/ErrorBox.js +++ b/packages/component/src/ErrorBox.js @@ -1,17 +1,17 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { localize } from './Localization/Localize'; -import connectToWebChat from './connectToWebChat'; import ScreenReaderText from './ScreenReaderText'; +import useLocalize from './hooks/useLocalize'; import useStyleSet from './hooks/useStyleSet'; -const ErrorBox = ({ children, language, message }) => { +const ErrorBox = ({ children, message }) => { const [{ errorBox: errorBoxStyleSet }] = useStyleSet(); + const errorMessageText = useLocalize('ErrorMessage'); return ( - +
    {message}
    {children}
    @@ -27,8 +27,7 @@ ErrorBox.defaultProps = { ErrorBox.propTypes = { children: PropTypes.any, - language: PropTypes.string.isRequired, message: PropTypes.string }; -export default connectToWebChat(({ language }) => ({ language }))(ErrorBox); +export default ErrorBox; diff --git a/packages/component/src/Localization/Localize.js b/packages/component/src/Localization/Localize.js index 0620bd65a1..4d1684c84c 100644 --- a/packages/component/src/Localization/Localize.js +++ b/packages/component/src/Localization/Localize.js @@ -1,8 +1,8 @@ // Localize is designed to be elaboratively return multiple results and possibly exceeding complexity requirement /* eslint complexity: "off" */ -import connectToWebChat from '../connectToWebChat'; import getLocaleString from './getLocaleString'; +import useLocalize from '../hooks/useLocalize'; import bgBG from './bg-BG'; import csCZ from './cs-CZ'; @@ -152,8 +152,6 @@ function localize(text, language, ...args) { return string || text; } -export default connectToWebChat(({ language }) => ({ language }))(({ args, language, text }) => - localize(text, language, ...(args || [])) -); +export default ({ args, text }) => useLocalize(text, ...(args || [])); export { getLocaleString, localize }; diff --git a/packages/component/src/SendBox/ConnectivityStatus.js b/packages/component/src/SendBox/ConnectivityStatus.js index d67f03f657..19902b3c5c 100644 --- a/packages/component/src/SendBox/ConnectivityStatus.js +++ b/packages/component/src/SendBox/ConnectivityStatus.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useState } from 'react'; -import { localize } from '../Localization/Localize'; import connectToWebChat from '../connectToWebChat'; import ErrorNotificationIcon from '../Attachment/Assets/ErrorNotificationIcon'; import ScreenReaderText from '../ScreenReaderText'; import SpinnerAnimation from '../Attachment/Assets/SpinnerAnimation'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; import WarningNotificationIcon from '../Attachment/Assets/WarningNotificationIcon'; @@ -51,7 +51,7 @@ DebouncedConnectivityStatus.propTypes = { const connectConnectivityStatus = (...selectors) => connectToWebChat(({ connectivityStatus, language }) => ({ connectivityStatus, language }), ...selectors); -const ConnectivityStatus = ({ connectivityStatus, language }) => { +const ConnectivityStatus = ({ connectivityStatus }) => { const [ { connectivityNotification: connectivityNotificationStyleSet, @@ -60,81 +60,82 @@ const ConnectivityStatus = ({ connectivityStatus, language }) => { } ] = useStyleSet(); - const renderConnectingSlow = useCallback(() => { - const localizedText = localize('SLOW_CONNECTION_NOTIFICATION', language); + const connectedNotificationText = useLocalize('CONNECTED_NOTIFICATION'); + const failedConnectionText = useLocalize('FAILED_CONNECTION_NOTIFICATION'); + const initialConnectionText = useLocalize('INITIAL_CONNECTION_NOTIFICATION'); + const interruptedConnectionText = useLocalize('INTERRUPTED_CONNECTION_NOTIFICATION'); + const renderErrorNotificationText = useLocalize('RENDER_ERROR_NOTIFICATION'); + const slowConnectionText = useLocalize('SLOW_CONNECTION_NOTIFICATION'); - return ( + const renderConnectingSlow = useCallback( + () => ( - +
    - {localizedText} + {slowConnectionText}
    - ); - }, [language, warningNotificationStyleSet]); - - const renderNotConnected = useCallback(() => { - const localizedText = localize('FAILED_CONNECTION_NOTIFICATION', language); + ), + [slowConnectionText, warningNotificationStyleSet] + ); - return ( + const renderNotConnected = useCallback( + () => ( - +
    - {localizedText} + {failedConnectionText}
    - ); - }, [language, errorNotificationStyleSet]); - - const renderUninitialized = useCallback(() => { - const localizedText = localize('INITIAL_CONNECTION_NOTIFICATION', language); + ), + [errorNotificationStyleSet, failedConnectionText] + ); - return ( + const renderUninitialized = useCallback( + () => ( - +
    - {localizedText} + {initialConnectionText}
    - ); - }, [language, connectivityNotificationStyleSet]); - - const renderReconnecting = useCallback(() => { - const localizedText = localize('INTERRUPTED_CONNECTION_NOTIFICATION', language); + ), + [connectivityNotificationStyleSet, initialConnectionText] + ); - return ( + const renderReconnecting = useCallback( + () => ( - +
    - {localizedText} + {interruptedConnectionText}
    - ); - }, [language, connectivityNotificationStyleSet]); - - const renderSagaError = useCallback(() => { - const localizedText = localize('RENDER_ERROR_NOTIFICATION', language); + ), + [connectivityNotificationStyleSet, interruptedConnectionText] + ); - return ( + const renderSagaError = useCallback( + () => ( - +
    - {localizedText} + {renderErrorNotificationText}
    - ); - }, [language, errorNotificationStyleSet]); - - const renderEmptyStatus = useCallback( - () => , - [language] + ), + [errorNotificationStyleSet, renderErrorNotificationText] ); + const renderEmptyStatus = useCallback(() => , [ + connectedNotificationText + ]); + const renderStatus = useCallback(() => { if (connectivityStatus === 'connectingslow') { return renderConnectingSlow(); @@ -173,8 +174,7 @@ const ConnectivityStatus = ({ connectivityStatus, language }) => { }; ConnectivityStatus.propTypes = { - connectivityStatus: PropTypes.string.isRequired, - language: PropTypes.string.isRequired + connectivityStatus: PropTypes.string.isRequired }; export default connectConnectivityStatus()(ConnectivityStatus); diff --git a/packages/component/src/SendBox/MicrophoneButton.js b/packages/component/src/SendBox/MicrophoneButton.js index 71b8e65b1d..9aa487b849 100644 --- a/packages/component/src/SendBox/MicrophoneButton.js +++ b/packages/component/src/SendBox/MicrophoneButton.js @@ -8,10 +8,10 @@ import memoize from 'memoize-one'; import PropTypes from 'prop-types'; import React from 'react'; -import { localize } from '../Localization/Localize'; import connectToWebChat from '../connectToWebChat'; import IconButton from './IconButton'; import MicrophoneIcon from './Assets/MicrophoneIcon'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; const { DictateState } = Constants; @@ -77,19 +77,21 @@ const connectMicrophoneButton = (...selectors) => { ); }; -const MicrophoneButton = ({ className, click, dictating, disabled, language }) => { +const MicrophoneButton = ({ className, click, dictating, disabled }) => { const [{ microphoneButton: microphoneButtonStyleSet }] = useStyleSet(); + const iconButtonAltText = useLocalize('Speak'); + const screenReaderText = useLocalize(dictating ? 'Microphone on' : 'Microphone off'); return (
    - +
    - {localize(dictating ? 'Microphone on' : 'Microphone off', language)} + {screenReaderText}
    ); @@ -105,8 +107,7 @@ MicrophoneButton.propTypes = { className: PropTypes.string, click: PropTypes.func.isRequired, dictating: PropTypes.bool, - disabled: PropTypes.bool, - language: PropTypes.string.isRequired + disabled: PropTypes.bool }; export default connectMicrophoneButton()(MicrophoneButton); diff --git a/packages/component/src/SendBox/SendButton.js b/packages/component/src/SendBox/SendButton.js index e6ec53515f..ed02956512 100644 --- a/packages/component/src/SendBox/SendButton.js +++ b/packages/component/src/SendBox/SendButton.js @@ -1,10 +1,10 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { localize } from '../Localization/Localize'; import connectToWebChat from '../connectToWebChat'; import IconButton from './IconButton'; import SendIcon from './Assets/SendIcon'; +import useLocalize from '../hooks/useLocalize'; const connectSendButton = (...selectors) => connectToWebChat( @@ -16,11 +16,15 @@ const connectSendButton = (...selectors) => ...selectors ); -const SendButton = ({ disabled, language, submitSendBox }) => ( - - - -); +const SendButton = ({ disabled, submitSendBox }) => { + const altText = useLocalize('Send'); + + return ( + + + + ); +}; SendButton.defaultProps = { disabled: false @@ -28,7 +32,6 @@ SendButton.defaultProps = { SendButton.propTypes = { disabled: PropTypes.bool, - language: PropTypes.string.isRequired, submitSendBox: PropTypes.func.isRequired }; diff --git a/packages/component/src/SendBox/TextBox.js b/packages/component/src/SendBox/TextBox.js index 8b5043913c..22a5da542f 100644 --- a/packages/component/src/SendBox/TextBox.js +++ b/packages/component/src/SendBox/TextBox.js @@ -4,8 +4,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { Context as TypeFocusSinkContext } from '../Utils/TypeFocusSink'; -import { localize } from '../Localization/Localize'; import connectToWebChat from '../connectToWebChat'; +import useLocalize from '../hooks/useLocalize'; import useStyleOptions from '../hooks/useStyleOptions'; import useStyleSet from '../hooks/useStyleSet'; @@ -55,12 +55,11 @@ const connectSendTextBox = (...selectors) => ...selectors ); -const TextBox = ({ className, disabled, language, onChange, onKeyPress, onSubmit, value }) => { +const TextBox = ({ className, disabled, onChange, onKeyPress, onSubmit, value }) => { const [{ sendBoxTextWrap }] = useStyleOptions(); const [{ sendBoxTextArea: sendBoxTextAreaStyleSet, sendBoxTextBox: sendBoxTextBoxStyleSet }] = useStyleSet(); - - const typeYourMessageString = localize('Type your message', language); - const sendBoxString = localize('Sendbox', language); + const sendBoxString = useLocalize('Sendbox'); + const typeYourMessageString = useLocalize('Type your message'); return (
    { +const TypingIndicator = ({ lastTypingAt }) => { const [{ typingAnimationDuration }] = useStyleOptions(); const [{ typingIndicator }] = useStyleSet(); + const animationAriaLabel = useLocalize('TypingIndicator'); const [showTyping, setShowTyping] = useState(false); @@ -31,15 +32,14 @@ const TypingIndicator = ({ language, lastTypingAt }) => { return ( showTyping && (
    - +
    ) ); }; TypingIndicator.propTypes = { - language: PropTypes.string.isRequired, lastTypingAt: PropTypes.any.isRequired }; -export default connectToWebChat(({ lastTypingAt, language }) => ({ lastTypingAt, language }))(TypingIndicator); +export default connectToWebChat(({ lastTypingAt }) => ({ lastTypingAt }))(TypingIndicator); diff --git a/packages/component/src/SendBox/UploadButton.js b/packages/component/src/SendBox/UploadButton.js index 2bd2acff8b..b567f2bdf9 100644 --- a/packages/component/src/SendBox/UploadButton.js +++ b/packages/component/src/SendBox/UploadButton.js @@ -3,11 +3,11 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { useCallback, useRef } from 'react'; -import { localize } from '../Localization/Localize'; import AttachmentIcon from './Assets/AttachmentIcon'; import connectToWebChat from '../connectToWebChat'; import downscaleImageToDataURL from '../Utils/downscaleImageToDataURL'; import IconButton from './IconButton'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; const ROOT_CSS = css({ @@ -81,10 +81,11 @@ const connectUploadButton = (...selectors) => ...selectors ); -const UploadButton = ({ disabled, language, sendFiles }) => { +const UploadButton = ({ disabled, sendFiles }) => { const [{ uploadButton: uploadButtonStyleSet }] = useStyleSet(); + const uploadFileString = useLocalize('Upload file'); + const inputRef = useRef(); - const uploadFileString = localize('Upload file', language); const { current } = inputRef; const handleClick = useCallback(() => { @@ -127,7 +128,6 @@ UploadButton.defaultProps = { UploadButton.propTypes = { disabled: PropTypes.bool, - language: PropTypes.string.isRequired, sendFiles: PropTypes.func.isRequired }; diff --git a/packages/component/src/Utils/AbsoluteTime.js b/packages/component/src/Utils/AbsoluteTime.js index b3707f1fc3..e7df0fcfe1 100644 --- a/packages/component/src/Utils/AbsoluteTime.js +++ b/packages/component/src/Utils/AbsoluteTime.js @@ -1,24 +1,25 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { getLocaleString, localize } from '../Localization/Localize'; -import connectToWebChat from '../connectToWebChat'; import ScreenReaderText from '../ScreenReaderText'; -const AbsoluteTime = ({ language, value }) => { - const localizedTime = getLocaleString(value, language); +import useLocalize from '../hooks/useLocalize'; +import useLocalizeDate from '../hooks/useLocalizeDate'; + +const AbsoluteTime = ({ value }) => { + const localizedTime = useLocalizeDate(value); + const text = useLocalize('SentAt') + localizedTime; return ( - + {localizedTime} ); }; AbsoluteTime.propTypes = { - language: PropTypes.string.isRequired, value: PropTypes.string.isRequired }; -export default connectToWebChat(({ language }) => ({ language }))(AbsoluteTime); +export default AbsoluteTime; diff --git a/packages/component/src/Utils/RelativeTime.js b/packages/component/src/Utils/RelativeTime.js index 48bf731aa2..44e1c2f919 100644 --- a/packages/component/src/Utils/RelativeTime.js +++ b/packages/component/src/Utils/RelativeTime.js @@ -1,10 +1,10 @@ import PropTypes from 'prop-types'; import React, { useCallback, useState } from 'react'; -import { getLocaleString, localize } from '../Localization/Localize'; -import connectToWebChat from '../connectToWebChat'; import ScreenReaderText from '../ScreenReaderText'; import Timer from './Timer'; +import useLocalize from '../hooks/useLocalize'; +import useLocalizeDate from '../hooks/useLocalizeDate'; // This function calculates the next absolute time that the timer should be fired based on the origin (original time received), interval, and current time. // If the origin is t=260, and we are currently at t=1000, nextTimer must return t=60260. @@ -20,18 +20,15 @@ function nextTimer(origin) { return time > now ? time : now + TIMER_INTERVAL - ((now - time) % TIMER_INTERVAL); } -function getText(language, value) { - return localize('X minutes ago', language, value); -} +const RelativeTime = ({ value }) => { + const localizedAbsoluteTime = useLocalize('SentAt') + useLocalizeDate(value); -const RelativeTime = ({ language, value }) => { + const text = useLocalize('X minutes ago', value); const [timer, setTimer] = useState(nextTimer(value)); + const handleInterval = useCallback(() => { setTimer(nextTimer(value)); - }, [value]); - - const localizedAbsoluteTime = localize('SentAt', language) + getLocaleString(value, language); - const text = getText(language, value); + }, [setTimer, value]); return ( @@ -43,8 +40,7 @@ const RelativeTime = ({ language, value }) => { }; RelativeTime.propTypes = { - language: PropTypes.string.isRequired, value: PropTypes.string.isRequired }; -export default connectToWebChat(({ language }) => ({ language }))(RelativeTime); +export default RelativeTime; diff --git a/packages/component/src/hooks/index.js b/packages/component/src/hooks/index.js index c9b6a4d1d7..ed7639f216 100644 --- a/packages/component/src/hooks/index.js +++ b/packages/component/src/hooks/index.js @@ -1,8 +1,20 @@ import useActivities from './useActivities'; +import useLanguage from './useLanguage'; +import useLocalize from './useLocalize'; +import useLocalizeDate from './useLocalizeDate'; import useReferenceGrammarID from './useReferenceGrammarID'; import useStyleOptions from './useStyleOptions'; import useStyleSet from './useStyleSet'; import { useSendBoxDictationStarted } from '../BasicSendBox'; -export { useActivities, useReferenceGrammarID, useSendBoxDictationStarted, useStyleOptions, useStyleSet }; +export { + useActivities, + useLanguage, + useLocalize, + useLocalizeDate, + useReferenceGrammarID, + useSendBoxDictationStarted, + useStyleOptions, + useStyleSet +}; diff --git a/packages/component/src/hooks/useLanguage.js b/packages/component/src/hooks/useLanguage.js new file mode 100644 index 0000000000..99aad41dd6 --- /dev/null +++ b/packages/component/src/hooks/useLanguage.js @@ -0,0 +1,5 @@ +import { useSelector } from '../WebChatReduxContext'; + +export default function useLanguage() { + return [useSelector(({ language }) => language)]; +} diff --git a/packages/component/src/hooks/useLocalize.js b/packages/component/src/hooks/useLocalize.js new file mode 100644 index 0000000000..68b7afc7ef --- /dev/null +++ b/packages/component/src/hooks/useLocalize.js @@ -0,0 +1,10 @@ +import useLanguage from './useLanguage'; + +import { localize } from '../Localization/Localize'; + +export default function useLocalize(text, ...args) { + const [language] = useLanguage(); + + // TODO: [P3] Use useMemo to cache the result. + return localize(text, language, ...args); +} diff --git a/packages/component/src/hooks/useLocalizeDate.js b/packages/component/src/hooks/useLocalizeDate.js new file mode 100644 index 0000000000..493c5e19c2 --- /dev/null +++ b/packages/component/src/hooks/useLocalizeDate.js @@ -0,0 +1,9 @@ +import useLanguage from './useLanguage'; + +import getLocaleString from '../Localization/getLocaleString'; + +export default function useLocalizeDate(date) { + const [language] = useLanguage(); + + return getLocaleString(date, language); +}