diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index ff99cbf38d3f4..37a9f693cf2fb 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -1,30 +1,15 @@ -/* eslint-disable react/prop-types */ import _ from 'underscore'; import React, {useMemo} from 'react'; -import {TouchableOpacity, Linking} from 'react-native'; import { TRenderEngineProvider, RenderHTMLConfigProvider, defaultHTMLElementModels, - TNodeChildrenRenderer, - splitBoxModelStyle, } from 'react-native-render-html'; import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; -import Config from '../../CONFIG'; +import htmlRenderers from './HTMLRenderers'; +import * as HTMLEngineUtils from './htmlEngineUtils'; import styles from '../../styles/styles'; -import * as StyleUtils from '../../styles/StyleUtils'; import fontFamily from '../../styles/fontFamily'; -import AnchorForCommentsOnly from '../AnchorForCommentsOnly'; -import InlineCodeBlock from '../InlineCodeBlock'; -import AttachmentModal from '../AttachmentModal'; -import ThumbnailImage from '../ThumbnailImage'; -import variables from '../../styles/variables'; -import themeColors from '../../styles/themes/default'; -import Text from '../Text'; -import withLocalize from '../withLocalize'; -import Navigation from '../../libs/Navigation/Navigation'; -import CONST from '../../CONST'; const propTypes = { /** Whether text elements should be selectable */ @@ -37,223 +22,6 @@ const defaultProps = { children: null, }; -const MAX_IMG_DIMENSIONS = 512; - -const EXTRA_FONTS = [ - fontFamily.GTA, - fontFamily.GTA_BOLD, - fontFamily.GTA_ITALIC, - fontFamily.MONOSPACE, - fontFamily.MONOSPACE_ITALIC, - fontFamily.MONOSPACE_BOLD, - fontFamily.MONOSPACE_BOLD_ITALIC, - fontFamily.SYSTEM, -]; - -/** - * Compute embedded maximum width from the available screen width. This function - * is used by the HTML component in the default renderer for img tags to scale - * down images that would otherwise overflow horizontally. - * - * @param {string} tagName - The name of the tag for which max width should be constrained. - * @param {number} contentWidth - The content width provided to the HTML - * component. - * @returns {number} The minimum between contentWidth and MAX_IMG_DIMENSIONS - */ -function computeEmbeddedMaxWidth(tagName, contentWidth) { - if (tagName === 'img') { - return Math.min(MAX_IMG_DIMENSIONS, contentWidth); - } - return contentWidth; -} - -/** - * Check if there is an ancestor node with name 'comment'. - * Finding node with name 'comment' flags that we are rendering a comment. - * @param {TNode} tnode - * @returns {Boolean} - */ -function isInsideComment(tnode) { - let currentNode = tnode; - while (currentNode.parent) { - if (currentNode.domNode.name === 'comment') { - return true; - } - currentNode = currentNode.parent; - } - return false; -} - -function AnchorRenderer(props) { - const htmlAttribs = props.tnode.attributes; - - // An auth token is needed to download Expensify chat attachments - const isAttachment = Boolean(htmlAttribs['data-expensify-source']); - const fileName = lodashGet(props.tnode, 'domNode.children[0].data', ''); - const parentStyle = lodashGet(props.tnode, 'parent.styles.nativeTextRet', {}); - const attrHref = htmlAttribs.href || ''; - const internalExpensifyPath = (attrHref.startsWith(CONST.NEW_EXPENSIFY_URL) && attrHref.replace(CONST.NEW_EXPENSIFY_URL, '')) - || (attrHref.startsWith(CONST.STAGING_NEW_EXPENSIFY_URL) && attrHref.replace(CONST.STAGING_NEW_EXPENSIFY_URL, '')); - - // If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation - // instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag) - if (internalExpensifyPath) { - return ( - Navigation.navigate(internalExpensifyPath)} - > - - - ); - } - - if (!isInsideComment(props.tnode)) { - // This is not a comment from a chat, the AnchorForCommentsOnly uses a Pressable to create a context menu on right click. - // We don't have this behaviour in other links in NewDot - // TODO: We should use TextLink, but I'm leaving it as Text for now because TextLink breaks the alignment in Android. - return ( - { - Linking.openURL(attrHref); - }} - > - - - ); - } - - return ( - - - - ); -} - -function CodeRenderer(props) { - // We split wrapper and inner styles - // "boxModelStyle" corresponds to border, margin, padding and backgroundColor - const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(props.style); - - // Get the correct fontFamily variant based in the fontStyle and fontWeight - const font = StyleUtils.getFontFamilyMonospace({ - fontStyle: textStyle.fontStyle, - fontWeight: textStyle.fontWeight, - }); - - const textStyleOverride = { - fontFamily: font, - - // We need to override this properties bellow that was defined in `textStyle` - // Because by default the `react-native-render-html` add a style in the elements, - // for example the tag has a fontWeight: "bold" and in the android it break the font - fontWeight: undefined, - fontStyle: undefined, - }; - - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - - return ( - - ); -} - -function EditedRenderer(props) { - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style', 'tnode']); - return ( - - {/* Native devices do not support margin between nested text */} - {' '} - {props.translate('reportActionCompose.edited')} - - ); -} - -function ImgRenderer(props) { - const htmlAttribs = props.tnode.attributes; - - // There are two kinds of images that need to be displayed: - // - // - Chat Attachment images - // - // Images uploaded by the user via the app or email. - // These have a full-sized image `htmlAttribs['data-expensify-source']` - // and a thumbnail `htmlAttribs.src`. Both of these URLs need to have - // an authToken added to them in order to control who - // can see the images. - // - // - Non-Attachment Images - // - // These could be hosted from anywhere (Expensify or another source) - // and are not protected by any kind of access control e.g. certain - // Concierge responder attachments are uploaded to S3 without any access - // control and thus require no authToken to verify access. - // - const isAttachment = Boolean(htmlAttribs['data-expensify-source']); - const originalFileName = htmlAttribs['data-name']; - let previewSource = htmlAttribs.src; - let source = isAttachment - ? htmlAttribs['data-expensify-source'] - : htmlAttribs.src; - - // Update the image URL so the images can be accessed depending on the config environment - previewSource = previewSource.replace( - Config.EXPENSIFY.URL_EXPENSIFY_COM, - Config.EXPENSIFY.URL_API_ROOT, - ); - source = source.replace( - Config.EXPENSIFY.URL_EXPENSIFY_COM, - Config.EXPENSIFY.URL_API_ROOT, - ); - - return ( - - {({show}) => ( - show()} - > - - - )} - - ); -} - // Declare nonstandard tags and their content model here const customHTMLElementModels = { edited: defaultHTMLElementModels.span.extend({ @@ -268,23 +36,6 @@ const customHTMLElementModels = { }), }; -// Define the custom renderer components -const renderers = { - a: AnchorRenderer, - code: CodeRenderer, - img: ImgRenderer, - edited: withLocalize(EditedRenderer), -}; - -const renderersProps = { - img: { - initialDimensions: { - width: MAX_IMG_DIMENSIONS, - height: MAX_IMG_DIMENSIONS, - }, - }, -}; - const defaultViewProps = {style: {alignItems: 'flex-start'}}; // We are using the explicit composite architecture for performance gains. @@ -303,14 +54,13 @@ const BaseHTMLEngineProvider = (props) => { tagsStyles={styles.webViewStyles.tagStyles} enableCSSInlineProcessing={false} dangerouslyDisableWhitespaceCollapsing - systemFonts={EXTRA_FONTS} + systemFonts={_.values(fontFamily)} > {props.children} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js new file mode 100644 index 0000000000000..62362d41b77dd --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -0,0 +1,77 @@ +import React from 'react'; +import {Linking} from 'react-native'; +import { + TNodeChildrenRenderer, +} from 'react-native-render-html'; +import lodashGet from 'lodash/get'; +import htmlRendererPropTypes from './htmlRendererPropTypes'; +import * as HTMLEngineUtils from '../htmlEngineUtils'; +import Text from '../../Text'; +import CONST from '../../../CONST'; +import styles from '../../../styles/styles'; +import Navigation from '../../../libs/Navigation/Navigation'; +import AnchorForCommentsOnly from '../../AnchorForCommentsOnly'; + +const AnchorRenderer = (props) => { + const htmlAttribs = props.tnode.attributes; + + // An auth token is needed to download Expensify chat attachments + const isAttachment = Boolean(htmlAttribs['data-expensify-source']); + const fileName = lodashGet(props.tnode, 'domNode.children[0].data', ''); + const parentStyle = lodashGet(props.tnode, 'parent.styles.nativeTextRet', {}); + const attrHref = htmlAttribs.href || ''; + const internalExpensifyPath = (attrHref.startsWith(CONST.NEW_EXPENSIFY_URL) && attrHref.replace(CONST.NEW_EXPENSIFY_URL, '')) + || (attrHref.startsWith(CONST.STAGING_NEW_EXPENSIFY_URL) && attrHref.replace(CONST.STAGING_NEW_EXPENSIFY_URL, '')); + + // If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation + // instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag) + if (internalExpensifyPath) { + return ( + Navigation.navigate(internalExpensifyPath)} + > + + + ); + } + + if (!HTMLEngineUtils.isInsideComment(props.tnode)) { + // This is not a comment from a chat, the AnchorForCommentsOnly uses a Pressable to create a context menu on right click. + // We don't have this behaviour in other links in NewDot + // TODO: We should use TextLink, but I'm leaving it as Text for now because TextLink breaks the alignment in Android. + return ( + Linking.openURL(attrHref)} + > + + + ); + } + + return ( + + + + ); +}; + +AnchorRenderer.propTypes = htmlRendererPropTypes; +AnchorRenderer.displayName = 'AnchorRenderer'; + +export default AnchorRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js new file mode 100644 index 0000000000000..1a351447d4eca --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js @@ -0,0 +1,45 @@ +import _ from 'underscore'; +import React from 'react'; +import {splitBoxModelStyle} from 'react-native-render-html'; +import htmlRendererPropTypes from './htmlRendererPropTypes'; +import * as StyleUtils from '../../../styles/StyleUtils'; +import InlineCodeBlock from '../../InlineCodeBlock'; + +const CodeRenderer = (props) => { + // We split wrapper and inner styles + // "boxModelStyle" corresponds to border, margin, padding and backgroundColor + const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(props.style); + + // Get the correct fontFamily variant based in the fontStyle and fontWeight + const font = StyleUtils.getFontFamilyMonospace({ + fontStyle: textStyle.fontStyle, + fontWeight: textStyle.fontWeight, + }); + + const textStyleOverride = { + fontFamily: font, + + // We need to override this properties bellow that was defined in `textStyle` + // Because by default the `react-native-render-html` add a style in the elements, + // for example the tag has a fontWeight: "bold" and in the android it break the font + fontWeight: undefined, + fontStyle: undefined, + }; + + const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); + + return ( + + ); +}; + +CodeRenderer.propTypes = htmlRendererPropTypes; +CodeRenderer.displayName = 'CodeRenderer'; + +export default CodeRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js new file mode 100644 index 0000000000000..0b04c3a885beb --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js @@ -0,0 +1,34 @@ +import _ from 'underscore'; +import React from 'react'; +import htmlRendererPropTypes from './htmlRendererPropTypes'; +import withLocalize, {withLocalizePropTypes} from '../../withLocalize'; +import Text from '../../Text'; +import variables from '../../../styles/variables'; +import themeColors from '../../../styles/themes/default'; +import styles from '../../../styles/styles'; + +const propTypes = { + ...htmlRendererPropTypes, + ...withLocalizePropTypes, +}; + +const EditedRenderer = (props) => { + const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style', 'tnode']); + return ( + + {/* Native devices do not support margin between nested text */} + {' '} + {props.translate('reportActionCompose.edited')} + + ); +}; + +EditedRenderer.propTypes = propTypes; +EditedRenderer.displayName = 'EditedRenderer'; + +export default withLocalize(EditedRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js new file mode 100644 index 0000000000000..e86bdedc87d1a --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js @@ -0,0 +1,71 @@ +import React from 'react'; +import {TouchableOpacity} from 'react-native'; +import htmlRendererPropTypes from './htmlRendererPropTypes'; +import Config from '../../../CONFIG'; +import AttachmentModal from '../../AttachmentModal'; +import styles from '../../../styles/styles'; +import ThumbnailImage from '../../ThumbnailImage'; + +const ImageRenderer = (props) => { + const htmlAttribs = props.tnode.attributes; + + // There are two kinds of images that need to be displayed: + // + // - Chat Attachment images + // + // Images uploaded by the user via the app or email. + // These have a full-sized image `htmlAttribs['data-expensify-source']` + // and a thumbnail `htmlAttribs.src`. Both of these URLs need to have + // an authToken added to them in order to control who + // can see the images. + // + // - Non-Attachment Images + // + // These could be hosted from anywhere (Expensify or another source) + // and are not protected by any kind of access control e.g. certain + // Concierge responder attachments are uploaded to S3 without any access + // control and thus require no authToken to verify access. + // + const isAttachment = Boolean(htmlAttribs['data-expensify-source']); + const originalFileName = htmlAttribs['data-name']; + let previewSource = htmlAttribs.src; + let source = isAttachment + ? htmlAttribs['data-expensify-source'] + : htmlAttribs.src; + + // Update the image URL so the images can be accessed depending on the config environment + previewSource = previewSource.replace( + Config.EXPENSIFY.URL_EXPENSIFY_COM, + Config.EXPENSIFY.URL_API_ROOT, + ); + source = source.replace( + Config.EXPENSIFY.URL_EXPENSIFY_COM, + Config.EXPENSIFY.URL_API_ROOT, + ); + + return ( + + {({show}) => ( + + + + )} + + ); +}; + +ImageRenderer.propTypes = htmlRendererPropTypes; +ImageRenderer.displayName = 'ImageRenderer'; + +export default ImageRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js b/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js new file mode 100644 index 0000000000000..f26806482e484 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types'; + +export default { + tnode: PropTypes.object, + TDefaultRenderer: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + key: PropTypes.string, + style: PropTypes.object, +}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/index.js new file mode 100644 index 0000000000000..8186dd57841b3 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.js @@ -0,0 +1,17 @@ +import AnchorRenderer from './AnchorRenderer'; +import CodeRenderer from './CodeRenderer'; +import EditedRenderer from './EditedRenderer'; +import ImageRenderer from './ImageRenderer'; + +/** + * This collection defines our custom renderers. It is a mapping from HTML tag type to the corresponding component. + */ +export default { + // Standard HTML tag renderers + a: AnchorRenderer, + code: CodeRenderer, + img: ImageRenderer, + + // Custom tag renderers + edited: EditedRenderer, +}; diff --git a/src/components/HTMLEngineProvider/htmlEngineUtils.js b/src/components/HTMLEngineProvider/htmlEngineUtils.js new file mode 100644 index 0000000000000..dc678b99d6017 --- /dev/null +++ b/src/components/HTMLEngineProvider/htmlEngineUtils.js @@ -0,0 +1,40 @@ +const MAX_IMG_DIMENSIONS = 512; + +/** + * Compute embedded maximum width from the available screen width. This function + * is used by the HTML component in the default renderer for img tags to scale + * down images that would otherwise overflow horizontally. + * + * @param {string} tagName - The name of the tag for which max width should be constrained. + * @param {number} contentWidth - The content width provided to the HTML + * component. + * @returns {number} The minimum between contentWidth and MAX_IMG_DIMENSIONS + */ +function computeEmbeddedMaxWidth(tagName, contentWidth) { + if (tagName === 'img') { + return Math.min(MAX_IMG_DIMENSIONS, contentWidth); + } + return contentWidth; +} + +/** + * Check if there is an ancestor node with name 'comment'. + * Finding node with name 'comment' flags that we are rendering a comment. + * @param {TNode} tnode + * @returns {Boolean} + */ +function isInsideComment(tnode) { + let currentNode = tnode; + while (currentNode.parent) { + if (currentNode.domNode.name === 'comment') { + return true; + } + currentNode = currentNode.parent; + } + return false; +} + +export { + computeEmbeddedMaxWidth, + isInsideComment, +};