diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 690f2fc6883a..bd4f72c63ec3 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -93,7 +93,6 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim <RenderHTMLConfigProvider defaultTextProps={defaultTextProps} defaultViewProps={defaultViewProps} - // @ts-expect-error TODO: Remove this once HTMLRenderers (https://github.com/Expensify/App/issues/25154) is migrated to TypeScript. renderers={htmlRenderers} computeEmbeddedMaxWidth={HTMLEngineUtils.computeEmbeddedMaxWidth} enableExperimentalBRCollapsing={enableExperimentalBRCollapsing} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx similarity index 77% rename from src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index c1cd5d6839a2..ca0e81ae9255 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -1,6 +1,6 @@ -import lodashGet from 'lodash/get'; import React from 'react'; import {TNodeChildrenRenderer} from 'react-native-render-html'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import AnchorForAttachmentsOnly from '@components/AnchorForAttachmentsOnly'; import AnchorForCommentsOnly from '@components/AnchorForCommentsOnly'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; @@ -10,21 +10,26 @@ import useThemeStyles from '@hooks/useThemeStyles'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import * as Link from '@userActions/Link'; import CONST from '@src/CONST'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -function AnchorRenderer(props) { +type AnchorRendererProps = CustomRendererProps<TBlock> & { + /** Key of the element */ + key?: string; +}; + +function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { const styles = useThemeStyles(); - const htmlAttribs = props.tnode.attributes; + const htmlAttribs = tnode.attributes; const {environmentURL} = useEnvironment(); // An auth token is needed to download Expensify chat attachments const isAttachment = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); - const displayName = lodashGet(props.tnode, 'domNode.children[0].data', ''); - const parentStyle = lodashGet(props.tnode, 'parent.styles.nativeTextRet', {}); + const tNodeChild = tnode?.domNode?.children?.[0]; + const displayName = tNodeChild && 'data' in tNodeChild && typeof tNodeChild.data === 'string' ? tNodeChild.data : ''; + const parentStyle = tnode.parent?.styles?.nativeTextRet ?? {}; const attrHref = htmlAttribs.href || htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || ''; const internalNewExpensifyPath = Link.getInternalNewExpensifyPath(attrHref); const internalExpensifyPath = Link.getInternalExpensifyPath(attrHref); - if (!HTMLEngineUtils.isChildOfComment(props.tnode)) { + if (!HTMLEngineUtils.isChildOfComment(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. @@ -34,7 +39,7 @@ function AnchorRenderer(props) { onPress={() => Link.openLink(attrHref, environmentURL, isAttachment)} suppressHighlighting > - <TNodeChildrenRenderer tnode={props.tnode} /> + <TNodeChildrenRenderer tnode={tnode} /> </Text> ); } @@ -58,18 +63,16 @@ function AnchorRenderer(props) { // eslint-disable-next-line react/jsx-props-no-multi-spaces target={htmlAttribs.target || '_blank'} rel={htmlAttribs.rel || 'noopener noreferrer'} - style={{...props.style, ...parentStyle, ...styles.textUnderlinePositionUnder, ...styles.textDecorationSkipInkNone}} - key={props.key} - displayName={displayName} + style={[parentStyle, styles.textUnderlinePositionUnder, styles.textDecorationSkipInkNone, style]} + key={key} // Only pass the press handler for internal links. For public links or whitelisted internal links fallback to default link handling onPress={internalNewExpensifyPath || internalExpensifyPath ? () => Link.openLink(attrHref, environmentURL, isAttachment) : undefined} > - <TNodeChildrenRenderer tnode={props.tnode} /> + <TNodeChildrenRenderer tnode={tnode} /> </AnchorForCommentsOnly> ); } -AnchorRenderer.propTypes = htmlRendererPropTypes; AnchorRenderer.displayName = 'AnchorRenderer'; export default AnchorRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx similarity index 65% rename from src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx index 1932eaaf8a4f..d1c11dc12ed5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx @@ -1,25 +1,30 @@ import React from 'react'; +import type {TextStyle} from 'react-native'; import {splitBoxModelStyle} from 'react-native-render-html'; -import _ from 'underscore'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; import InlineCodeBlock from '@components/InlineCodeBlock'; import useStyleUtils from '@hooks/useStyleUtils'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -function CodeRenderer(props) { +type CodeRendererProps = CustomRendererProps<TText | TPhrasing> & { + /** Key of the element */ + key?: string; +}; + +function CodeRenderer({TDefaultRenderer, key, style, ...defaultRendererProps}: CodeRendererProps) { const StyleUtils = useStyleUtils(); // We split wrapper and inner styles // "boxModelStyle" corresponds to border, margin, padding and backgroundColor - const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(props.style); + const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(style ?? {}); - // Get the correct fontFamily variant based in the fontStyle and fontWeight + /** Get the default fontFamily variant */ const font = StyleUtils.getFontFamilyMonospace({ - fontStyle: textStyle.fontStyle, - fontWeight: textStyle.fontWeight, + fontStyle: undefined, + fontWeight: undefined, }); // Determine the font size for the code based on whether it's inside an H1 element. - const isInsideH1 = HTMLEngineUtils.isChildOfH1(props.tnode); + const isInsideH1 = HTMLEngineUtils.isChildOfH1(defaultRendererProps.tnode); const fontSize = StyleUtils.getCodeFontSize(isInsideH1); @@ -34,20 +39,17 @@ function CodeRenderer(props) { fontStyle: undefined, }; - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - return ( <InlineCodeBlock - defaultRendererProps={defaultRendererProps} - TDefaultRenderer={props.TDefaultRenderer} + defaultRendererProps={{...defaultRendererProps, style: style as TextStyle}} + TDefaultRenderer={TDefaultRenderer} boxModelStyle={boxModelStyle} textStyle={{...textStyle, ...textStyleOverride}} - key={props.key} + key={key} /> ); } -CodeRenderer.propTypes = htmlRendererPropTypes; CodeRenderer.displayName = 'CodeRenderer'; export default CodeRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx similarity index 61% rename from src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx index 9ff5fdecae13..03f7a5dbedf7 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx @@ -1,23 +1,17 @@ import React from 'react'; -import _ from 'underscore'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -const propTypes = { - ...htmlRendererPropTypes, - ...withLocalizePropTypes, -}; - -function EditedRenderer(props) { +function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps}: CustomRendererProps<TBlock>) { const theme = useTheme(); const styles = useThemeStyles(); - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style', 'tnode']); - const isPendingDelete = Boolean(props.tnode.attributes.deleted !== undefined); + const {translate} = useLocalize(); + const isPendingDelete = Boolean(tnode.attributes.deleted !== undefined); return ( <Text> <Text @@ -33,13 +27,12 @@ function EditedRenderer(props) { color={theme.textSupporting} style={[styles.editedLabelStyles, isPendingDelete && styles.offlineFeedback.deleted]} > - {props.translate('reportActionCompose.edited')} + {translate('reportActionCompose.edited')} </Text> </Text> ); } -EditedRenderer.propTypes = propTypes; EditedRenderer.displayName = 'EditedRenderer'; -export default withLocalize(EditedRenderer); +export default EditedRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx similarity index 77% rename from src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index f60377c842ea..3e6119ff279f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -1,6 +1,7 @@ -import lodashGet from 'lodash/get'; import React, {memo} from 'react'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import ThumbnailImage from '@components/ThumbnailImage'; @@ -12,15 +13,22 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; +import type {User} from '@src/types/onyx'; -const propTypes = {...htmlRendererPropTypes}; +type ImageRendererWithOnyxProps = { + /** Current user */ + // Following line is disabled because the onyx prop is only being used on the memo HOC + // eslint-disable-next-line react/no-unused-prop-types + user: OnyxEntry<User>; +}; -function ImageRenderer(props) { +type ImageRendererProps = ImageRendererWithOnyxProps & CustomRendererProps<TBlock>; + +function ImageRenderer({tnode}: ImageRendererProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const htmlAttribs = props.tnode.attributes; + const htmlAttribs = tnode.attributes; // There are two kinds of images that need to be displayed: // @@ -63,20 +71,10 @@ function ImageRenderer(props) { <PressableWithoutFocus style={[styles.noOutline]} onPress={() => { - const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report.reportID, source); + const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report?.reportID ?? '', source); Navigation.navigate(route); }} - onLongPress={(event) => - showContextMenuForReport( - // Imitate the web event for native renderers - {nativeEvent: {...(event.nativeEvent || {}), target: {tagName: 'IMG'}}}, - anchor, - report.reportID, - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report), - ) - } + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} > @@ -93,18 +91,15 @@ function ImageRenderer(props) { ); } -ImageRenderer.propTypes = propTypes; ImageRenderer.displayName = 'ImageRenderer'; -export default withOnyx({ +export default withOnyx<ImageRendererProps, ImageRendererWithOnyxProps>({ user: { key: ONYXKEYS.USER, }, })( memo( ImageRenderer, - (prevProps, nextProps) => - lodashGet(prevProps, 'tnode.attributes') === lodashGet(nextProps, 'tnode.attributes') && - lodashGet(prevProps, 'user.shouldUseStagingServer') === lodashGet(nextProps, 'user.shouldUseStagingServer'), + (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes && prevProps.user?.shouldUseStagingServer === nextProps.user?.shouldUseStagingServer, ), ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx similarity index 53% rename from src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx index 93ede229876d..09dc8cf9f641 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx @@ -1,26 +1,30 @@ import React from 'react'; +import type {TextStyle} from 'react-native'; +import {StyleSheet} from 'react-native'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {TNodeChildrenRenderer} from 'react-native-render-html'; -import _ from 'underscore'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -function MentionHereRenderer(props) { +function MentionHereRenderer({style, tnode}: CustomRendererProps<TText | TPhrasing>) { const StyleUtils = useStyleUtils(); + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + return ( <Text> <Text // Passing the true value to the function as here mention is always for the current user color={StyleUtils.getMentionTextColor(true)} - style={[_.omit(props.style, 'color'), StyleUtils.getMentionStyle(true)]} + style={[styleWithoutColor, StyleUtils.getMentionStyle(true)]} > - <TNodeChildrenRenderer tnode={props.tnode} /> + <TNodeChildrenRenderer tnode={tnode} /> </Text> </Text> ); } -MentionHereRenderer.propTypes = htmlRendererPropTypes; MentionHereRenderer.displayName = 'HereMentionRenderer'; export default MentionHereRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js deleted file mode 100644 index eb4f5f763dbe..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js +++ /dev/null @@ -1,120 +0,0 @@ -import {cloneDeep} from 'lodash'; -import lodashGet from 'lodash/get'; -import React from 'react'; -import {TNodeChildrenRenderer} from 'react-native-render-html'; -import _ from 'underscore'; -import {usePersonalDetails} from '@components/OnyxProvider'; -import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; -import Text from '@components/Text'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; -import Navigation from '@libs/Navigation/Navigation'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; -import CONST from '@src/CONST'; -import * as LoginUtils from '@src/libs/LoginUtils'; -import ROUTES from '@src/ROUTES'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; - -const propTypes = { - ...htmlRendererPropTypes, - - /** Current user personal details */ - currentUserPersonalDetails: personalDetailsPropType.isRequired, -}; - -function MentionUserRenderer(props) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - const htmlAttributeAccountID = lodashGet(props.tnode.attributes, 'accountid'); - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; - - let accountID; - let displayNameOrLogin; - let navigationRoute; - const tnode = cloneDeep(props.tnode); - - const getMentionDisplayText = (displayText, userAccountID, userLogin = '') => { - // If the userAccountID does not exist, this is an email-based mention so the displayText must be an email. - // If the userAccountID exists but userLogin is different from displayText, this means the displayText is either user display name, Hidden, or phone number, in which case we should return it as is. - if (userAccountID && userLogin !== displayText) { - return displayText; - } - - // If the emails are not in the same private domain, we also return the displayText - if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, lodashGet(props.currentUserPersonalDetails, 'login', ''))) { - return displayText; - } - - // Otherwise, the emails must be of the same private domain, so we should remove the domain part - return displayText.split('@')[0]; - }; - - if (!_.isEmpty(htmlAttributeAccountID)) { - const user = lodashGet(personalDetails, htmlAttributeAccountID); - accountID = parseInt(htmlAttributeAccountID, 10); - displayNameOrLogin = lodashGet(user, 'displayName', '') || LocalePhoneNumber.formatPhoneNumber(lodashGet(user, 'login', '')) || translate('common.hidden'); - displayNameOrLogin = getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID, lodashGet(user, 'login', '')); - navigationRoute = ROUTES.PROFILE.getRoute(htmlAttributeAccountID); - } else if (!_.isEmpty(tnode.data)) { - // We need to remove the LTR unicode and leading @ from data as it is not part of the login - displayNameOrLogin = tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); - // We need to replace tnode.data here because we will pass it to TNodeChildrenRenderer below - tnode.data = tnode.data.replace(displayNameOrLogin, getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID)); - - accountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])); - navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); - } else { - // If neither an account ID or email is provided, don't render anything - return null; - } - - const isOurMention = accountID === props.currentUserPersonalDetails.accountID; - - return ( - <ShowContextMenuContext.Consumer> - {({anchor, report, action, checkIfContextMenuActive}) => ( - <Text - suppressHighlighting - onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} - onPress={(event) => { - event.preventDefault(); - Navigation.navigate(navigationRoute); - }} - role={CONST.ROLE.LINK} - accessibilityLabel={`/${navigationRoute}`} - > - <UserDetailsTooltip - accountID={accountID} - fallbackUserDetails={{ - displayName: displayNameOrLogin, - }} - > - <Text - style={[styles.link, _.omit(props.style, 'color'), StyleUtils.getMentionStyle(isOurMention), {color: StyleUtils.getMentionTextColor(isOurMention)}]} - role={CONST.ROLE.LINK} - testID="span" - href={`/${navigationRoute}`} - // eslint-disable-next-line react/jsx-props-no-spreading - {...defaultRendererProps} - > - {!_.isEmpty(htmlAttributeAccountID) ? `@${displayNameOrLogin}` : <TNodeChildrenRenderer tnode={tnode} />} - </Text> - </UserDetailsTooltip> - </Text> - )} - </ShowContextMenuContext.Consumer> - ); -} - -MentionUserRenderer.propTypes = propTypes; -MentionUserRenderer.displayName = 'MentionUserRenderer'; - -export default withCurrentUserPersonalDetails(MentionUserRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx new file mode 100644 index 000000000000..ad9cfb4e6384 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -0,0 +1,98 @@ +import isEmpty from 'lodash/isEmpty'; +import React from 'react'; +import {StyleSheet} from 'react-native'; +import type {TextStyle} from 'react-native'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; +import {TNodeChildrenRenderer} from 'react-native-render-html'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; +import Text from '@components/Text'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type MentionUserRendererProps = WithCurrentUserPersonalDetailsProps & CustomRendererProps<TText | TPhrasing>; + +function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersonalDetails, ...defaultRendererProps}: MentionUserRendererProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const htmlAttribAccountID = tnode.attributes.accountid; + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + + let accountID: number; + let displayNameOrLogin: string; + let navigationRoute: Route; + + if (!isEmpty(htmlAttribAccountID)) { + const user = personalDetails.htmlAttribAccountID; + accountID = parseInt(htmlAttribAccountID, 10); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || user?.displayName || translate('common.hidden'); + navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); + } else if ('data' in tnode && !isEmptyObject(tnode.data)) { + // We need to remove the LTR unicode and leading @ from data as it is not part of the login + displayNameOrLogin = tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); + + accountID = PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])?.[0]; + navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); + } else { + // If neither an account ID or email is provided, don't render anything + return null; + } + + const isOurMention = accountID === currentUserPersonalDetails.accountID; + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + + return ( + <ShowContextMenuContext.Consumer> + {({anchor, report, action, checkIfContextMenuActive}) => ( + <Text + suppressHighlighting + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onPress={(event) => { + event.preventDefault(); + Navigation.navigate(navigationRoute); + }} + role={CONST.ROLE.LINK} + accessibilityLabel={`/${navigationRoute}`} + > + <UserDetailsTooltip + accountID={accountID} + fallbackUserDetails={{ + displayName: displayNameOrLogin, + }} + > + <Text + // eslint-disable-next-line react/jsx-props-no-spreading + {...defaultRendererProps} + style={[styles.link, styleWithoutColor, StyleUtils.getMentionStyle(isOurMention), {color: StyleUtils.getMentionTextColor(isOurMention)}]} + role={CONST.ROLE.LINK} + testID="span" + href={`/${navigationRoute}`} + > + {htmlAttribAccountID ? `@${displayNameOrLogin}` : <TNodeChildrenRenderer tnode={tnode} />} + </Text> + </UserDetailsTooltip> + </Text> + )} + </ShowContextMenuContext.Consumer> + ); +} + +MentionUserRenderer.displayName = 'MentionUserRenderer'; + +export default withCurrentUserPersonalDetails(MentionUserRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx index 775bf75294eb..6124b59dfd49 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx @@ -1,14 +1,9 @@ import React from 'react'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; -type NextStepEmailRendererProps = { - tnode: { - data: string; - }; -}; - -function NextStepEmailRenderer({tnode}: NextStepEmailRendererProps) { +function NextStepEmailRenderer({tnode}: CustomRendererProps<TText | TPhrasing>) { const styles = useThemeStyles(); return ( @@ -16,7 +11,7 @@ function NextStepEmailRenderer({tnode}: NextStepEmailRendererProps) { nativeID="email-with-break-opportunities" style={[styles.breakWord, styles.textLabelSupporting, styles.textStrong]} > - {tnode.data} + {'data' in tnode ? tnode.data : ''} </Text> ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx similarity index 53% rename from src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 27eff02d63ea..798ec8f64194 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -1,52 +1,47 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {GestureResponderEvent} from 'react-native'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; -import withLocalize from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -const propTypes = { +type PreRendererProps = CustomRendererProps<TBlock> & { /** Press in handler for the code block */ - onPressIn: PropTypes.func, + onPressIn?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** Press out handler for the code block */ - onPressOut: PropTypes.func, + onPressOut?: (event?: GestureResponderEvent | KeyboardEvent) => void; + + /** Long press handler for the code block */ + onLongPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** The position of this React element relative to the parent React element, starting at 0 */ - renderIndex: PropTypes.number.isRequired, + renderIndex: number; /** The total number of elements children of this React element parent */ - renderLength: PropTypes.number.isRequired, - - ...htmlRendererPropTypes, -}; - -const defaultProps = { - onPressIn: undefined, - onPressOut: undefined, + renderLength: number; }; -function PreRenderer(props) { +function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...defaultRendererProps}: PreRendererProps) { const styles = useThemeStyles(); - const TDefaultRenderer = props.TDefaultRenderer; - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'onPressIn', 'onPressOut', 'onLongPress']); - const isLast = props.renderIndex === props.renderLength - 1; + const {translate} = useLocalize(); + const isLast = defaultRendererProps.renderIndex === defaultRendererProps.renderLength - 1; return ( - <View style={[isLast ? styles.mt2 : styles.mv2]}> + <View style={isLast ? styles.mt2 : styles.mv2}> <ShowContextMenuContext.Consumer> {({anchor, report, action, checkIfContextMenuActive}) => ( <PressableWithoutFeedback - onPressIn={props.onPressIn} - onPressOut={props.onPressOut} - onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onPress={onPressIn ?? (() => {})} + onPressIn={onPressIn} + onPressOut={onPressOut} + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} role={CONST.ROLE.PRESENTATION} - accessibilityLabel={props.translate('accessibilityHints.prestyledText')} + accessibilityLabel={translate('accessibilityHints.prestyledText')} > <View> {/* eslint-disable-next-line react/jsx-props-no-spreading */} @@ -60,7 +55,5 @@ function PreRenderer(props) { } PreRenderer.displayName = 'PreRenderer'; -PreRenderer.propTypes = propTypes; -PreRenderer.defaultProps = defaultProps; -export default withLocalize(PreRenderer); +export default PreRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js b/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js deleted file mode 100644 index f26806482e48..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js +++ /dev/null @@ -1,8 +0,0 @@ -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.ts similarity index 74% rename from src/components/HTMLEngineProvider/HTMLRenderers/index.js rename to src/components/HTMLEngineProvider/HTMLRenderers/index.ts index 9d0dab731792..f2c8cbe89a98 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts @@ -1,3 +1,4 @@ +import type {CustomTagRendererRecord} from 'react-native-render-html'; import AnchorRenderer from './AnchorRenderer'; import CodeRenderer from './CodeRenderer'; import EditedRenderer from './EditedRenderer'; @@ -10,7 +11,7 @@ import PreRenderer from './PreRenderer'; /** * This collection defines our custom renderers. It is a mapping from HTML tag type to the corresponding component. */ -export default { +const HTMLEngineProviderComponentList: CustomTagRendererRecord = { // Standard HTML tag renderers a: AnchorRenderer, code: CodeRenderer, @@ -20,7 +21,11 @@ export default { // Custom tag renderers edited: EditedRenderer, pre: PreRenderer, + /* eslint-disable @typescript-eslint/naming-convention */ 'mention-user': MentionUserRenderer, 'mention-here': MentionHereRenderer, 'next-step-email': NextStepEmailRenderer, + /* eslint-enable @typescript-eslint/naming-convention */ }; + +export default HTMLEngineProviderComponentList; diff --git a/src/components/InlineCodeBlock/index.native.tsx b/src/components/InlineCodeBlock/index.native.tsx index 3a70308fa0cc..85d02b7239ca 100644 --- a/src/components/InlineCodeBlock/index.native.tsx +++ b/src/components/InlineCodeBlock/index.native.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import type {TText} from 'react-native-render-html'; import useThemeStyles from '@hooks/useThemeStyles'; import type InlineCodeBlockProps from './types'; +import type {TTextOrTPhrasing} from './types'; import WrappedText from './WrappedText'; -function InlineCodeBlock<TComponent extends TText>({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps<TComponent>) { +function InlineCodeBlock<TComponent extends TTextOrTPhrasing>({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps<TComponent>) { const styles = useThemeStyles(); return ( @@ -16,7 +16,7 @@ function InlineCodeBlock<TComponent extends TText>({TDefaultRenderer, defaultRen textStyles={textStyle} wordStyles={[boxModelStyle, styles.codeWordStyle]} > - {defaultRendererProps.tnode.data} + {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data} </WrappedText> </TDefaultRenderer> ); diff --git a/src/components/InlineCodeBlock/index.tsx b/src/components/InlineCodeBlock/index.tsx index 0802d4752661..593a08aaad5e 100644 --- a/src/components/InlineCodeBlock/index.tsx +++ b/src/components/InlineCodeBlock/index.tsx @@ -1,10 +1,10 @@ import React from 'react'; import {StyleSheet} from 'react-native'; -import type {TText} from 'react-native-render-html'; import Text from '@components/Text'; import type InlineCodeBlockProps from './types'; +import type {TTextOrTPhrasing} from './types'; -function InlineCodeBlock<TComponent extends TText>({TDefaultRenderer, textStyle, defaultRendererProps, boxModelStyle}: InlineCodeBlockProps<TComponent>) { +function InlineCodeBlock<TComponent extends TTextOrTPhrasing>({TDefaultRenderer, textStyle, defaultRendererProps, boxModelStyle}: InlineCodeBlockProps<TComponent>) { const flattenTextStyle = StyleSheet.flatten(textStyle); const {textDecorationLine, ...textStyles} = flattenTextStyle; @@ -13,7 +13,7 @@ function InlineCodeBlock<TComponent extends TText>({TDefaultRenderer, textStyle, // eslint-disable-next-line react/jsx-props-no-spreading {...defaultRendererProps} > - <Text style={[boxModelStyle, textStyles]}>{defaultRendererProps.tnode.data}</Text> + <Text style={[boxModelStyle, textStyles]}>{'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data}</Text> </TDefaultRenderer> ); } diff --git a/src/components/InlineCodeBlock/types.ts b/src/components/InlineCodeBlock/types.ts index ae847b293a60..cc05f36a20cf 100644 --- a/src/components/InlineCodeBlock/types.ts +++ b/src/components/InlineCodeBlock/types.ts @@ -1,7 +1,9 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import type {TDefaultRenderer, TDefaultRendererProps, TText} from 'react-native-render-html'; +import type {TDefaultRenderer, TDefaultRendererProps, TPhrasing, TText} from 'react-native-render-html'; -type InlineCodeBlockProps<TComponent extends TText> = { +type TTextOrTPhrasing = TText | TPhrasing; + +type InlineCodeBlockProps<TComponent extends TTextOrTPhrasing> = { TDefaultRenderer: TDefaultRenderer<TComponent>; textStyle: StyleProp<TextStyle>; defaultRendererProps: TDefaultRendererProps<TComponent>; @@ -9,3 +11,4 @@ type InlineCodeBlockProps<TComponent extends TText> = { }; export default InlineCodeBlockProps; +export type {TTextOrTPhrasing}; diff --git a/src/styles/utils/addOutlineWidth/types.ts b/src/styles/utils/addOutlineWidth/types.ts index 45975b72dc8a..d3a2538cd1b2 100644 --- a/src/styles/utils/addOutlineWidth/types.ts +++ b/src/styles/utils/addOutlineWidth/types.ts @@ -1,6 +1,6 @@ -import type {TextStyle} from 'react-native'; +import type {TextStyle, ViewStyle} from 'react-native'; import type {ThemeColors} from '@styles/theme/types'; -type AddOutlineWidth = (theme: ThemeColors, obj: TextStyle, val?: number, hasError?: boolean) => TextStyle; +type AddOutlineWidth = <TStyle extends TextStyle | ViewStyle>(theme: ThemeColors, obj: TStyle, val?: number, hasError?: boolean) => TStyle; export default AddOutlineWidth; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index b2c6d00559a7..d4d2b427cbe8 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1347,7 +1347,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ /** * Returns style object for the user mention component based on whether the mention is ours or not. */ - getMentionStyle: (isOurMention: boolean): ViewStyle => { + getMentionStyle: (isOurMention: boolean): TextStyle => { const backgroundColor = isOurMention ? theme.ourMentionBG : theme.mentionBG; return { backgroundColor,