diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 91b8b0fc4483..ecf5e769ba97 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -1,11 +1,13 @@ -import React, {Fragment, useCallback, useRef} from 'react'; +import React, {Fragment, useCallback, useMemo, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; import {View} from 'react-native'; +import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; +import StringUtils from '@libs/StringUtils'; import DisplayNamesTooltipItem from './DisplayNamesTooltipItem'; import type DisplayNamesProps from './types'; @@ -47,6 +49,8 @@ function DisplayNamesWithToolTip({shouldUseFullTitle, fullTitle, displayNamesWit return textNodeRight > containerRight ? -(tooltipX - newToolX) : 0; }, []); + const title = useMemo(() => (StringUtils.containsHtml(fullTitle) ? : ReportUtils.formatReportLastMessageText(fullTitle)), [fullTitle]); + return ( // Tokenization of string only support prop numberOfLines on Web {shouldUseFullTitle - ? ReportUtils.formatReportLastMessageText(fullTitle) + ? title : displayNamesWithTooltips?.map(({displayName, accountID, avatar, login}, index) => ( // eslint-disable-next-line react/no-array-index-key diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx index c66d1698bbd6..ea4fba77c90c 100644 --- a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type {StyleProp, TextStyle} from 'react-native'; +import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; +import StringUtils from '@libs/StringUtils'; type DisplayNamesWithoutTooltipProps = { /** The full title of the DisplayNames component (not split up) */ @@ -19,12 +21,14 @@ type DisplayNamesWithoutTooltipProps = { function DisplayNamesWithoutTooltip({textStyles = [], numberOfLines = 1, fullTitle = '', renderAdditionalText}: DisplayNamesWithoutTooltipProps) { const styles = useThemeStyles(); + const title = StringUtils.containsHtml(fullTitle) ? : fullTitle; + return ( - {fullTitle} + {title} {renderAdditionalText?.()} ); diff --git a/src/components/DisplayNames/index.native.tsx b/src/components/DisplayNames/index.native.tsx index ceee34586e8b..044b037ade01 100644 --- a/src/components/DisplayNames/index.native.tsx +++ b/src/components/DisplayNames/index.native.tsx @@ -1,11 +1,26 @@ import React from 'react'; +import {View} from 'react-native'; +import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import StringUtils from '@libs/StringUtils'; import type DisplayNamesProps from './types'; // As we don't have to show tooltips of the Native platform so we simply render the full display names list. function DisplayNames({accessibilityLabel, fullTitle, textStyles = [], numberOfLines = 1, renderAdditionalText}: DisplayNamesProps) { + const styles = useThemeStyles(); const {translate} = useLocalize(); + + const containsHtml = StringUtils.containsHtml(fullTitle); + if (containsHtml) { + return ( + + + + ); + } + return ( ({selectable: textSelectable, allowFontScaling: false, textBreakStrategy: 'simple'}), [textSelectable]); - const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]}; + const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText, styles.mw100]}; return ( ; + +function ThreadTitleRenderer({tnode}: ThreadTitleRendererProps) { + const styles = useThemeStyles(); + + const renderFn = (node: TNode) => { + const children = node.children; + + return children.map((child) => { + if (child.tagName === 'blockquote') { + return ( + + {renderFn(child)} + + ); + } + + // HTML node + if (child.tagName) { + return ( + + + + ); + } + + // TText node + if ('data' in child) { + return ( + + {child.data} + + ); + } + + return ( + + {renderFn(child)} + + ); + }); + }; + + return {renderFn(tnode)}; +} + +ThreadTitleRenderer.displayName = 'ThreadTitleRenderer'; + +export default ThreadTitleRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts index ce24584048b0..1db5dae6e5d9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts @@ -9,6 +9,7 @@ import MentionReportRenderer from './MentionReportRenderer'; import MentionUserRenderer from './MentionUserRenderer'; import NextStepEmailRenderer from './NextStepEmailRenderer'; import PreRenderer from './PreRenderer'; +import ThreadTitleRenderer from './ThreadTitleRenderer'; import VideoRenderer from './VideoRenderer'; /** @@ -30,6 +31,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = { 'mention-here': MentionHereRenderer, emoji: EmojiRenderer, 'next-step-email': NextStepEmailRenderer, + 'thread-title': ThreadTitleRenderer, /* eslint-enable @typescript-eslint/naming-convention */ }; diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index 3045c15c471b..524bb6425f57 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -15,6 +15,9 @@ type WrappedTextProps = ChildrenProps & { * Style for each individual word (token) in the text. Note that a token can also include whitespace characters between words. */ wordStyles?: StyleProp; + + /** Number of lines before wrapping */ + numberOfLines?: number; }; /** @@ -40,7 +43,7 @@ function containsEmoji(text: string): boolean { return CONST.REGEX.EMOJIS.test(text); } -function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { +function WrappedText({children, wordStyles, textStyles, numberOfLines}: WrappedTextProps) { const styles = useThemeStyles(); if (typeof children !== 'string') { @@ -62,7 +65,10 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { style={styles.codeWordWrapper} > - + {Array.from(colText).map((char, charIndex) => containsOnlyEmojis(char) ? ( ({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps) { const styles = useThemeStyles(); const data = getCurrentData(defaultRendererProps); + const numberOfLines = defaultRendererProps.propsFromParent?.numberOfLines; return ( ({TDefaultRenderer, {data} diff --git a/src/components/InlineCodeBlock/index.tsx b/src/components/InlineCodeBlock/index.tsx index e1a89719bb82..a334ac388198 100644 --- a/src/components/InlineCodeBlock/index.tsx +++ b/src/components/InlineCodeBlock/index.tsx @@ -60,13 +60,19 @@ function InlineCodeBlock({TDefaultRenderer, const {textDecorationLine, ...textStyles} = flattenTextStyle; const elements = renderElements(defaultRendererProps, textStyles, styles); + const numberOfLines = defaultRendererProps.propsFromParent?.numberOfLines; return ( - {elements} + + {elements} + ); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index fe00c8f699e0..8496e2807f65 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6,6 +6,7 @@ import lodashEscape from 'lodash/escape'; import lodashFindLastIndex from 'lodash/findLastIndex'; import lodashIntersection from 'lodash/intersection'; import lodashIsEqual from 'lodash/isEqual'; +import lodashUnescape from 'lodash/unescape'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -78,6 +79,7 @@ import * as PhoneNumber from './PhoneNumber'; import * as PolicyUtils from './PolicyUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; +import StringUtils from './StringUtils'; import * as TransactionUtils from './TransactionUtils'; import * as Url from './Url'; import * as UserUtils from './UserUtils'; @@ -3095,28 +3097,6 @@ function getInvoicePayerName(report: OnyxEntry): string { return getPolicyName(report, false, allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiver?.policyID}`]); } -/** - * Get the report action message for a report action. - */ -function getReportActionMessage(reportAction: ReportAction | EmptyObject, parentReportID?: string) { - if (isEmptyObject(reportAction)) { - return ''; - } - if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { - return Localize.translateLocal('iou.heldExpense'); - } - if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { - return Localize.translateLocal('iou.unheldExpense'); - } - if (ReportActionsUtils.isApprovedOrSubmittedReportAction(reportAction)) { - return ReportActionsUtils.getReportActionMessageText(reportAction); - } - if (ReportActionsUtils.isReimbursementQueuedAction(reportAction)) { - return getReimbursementQueuedActionMessage(reportAction, getReport(parentReportID), false); - } - return Str.removeSMSDomain(reportAction?.message?.[0]?.text ?? ''); -} - /** * Get the title for an invoice room. */ @@ -3140,10 +3120,66 @@ function getInvoicesChatName(report: OnyxEntry): string { return getPolicyName(report, false, allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]); } +/** + * Get the formatted title in HTML for a thread based on parent message. + * Only the first line of the message should display. + */ +function getThreadReportNameHtml(reportActionMessageHtml: string): string { + const blockTags = ['br', 'h1', 'pre', 'div', 'blockquote', 'p', 'li', 'div']; + const blockTagRegExp = `(?:<\\/?(?:${blockTags.join('|')})(?:[^>]*)>|\\r\\n|\\n|\\r)`; + const threadHeaderHtmlRegExp = new RegExp(`^(?:<([^>]+)>)?((?:(?!${blockTagRegExp}).)*)(${blockTagRegExp}.*)`, 'gmi'); + return reportActionMessageHtml.replace(threadHeaderHtmlRegExp, (match, g1: string, g2: string) => { + if (!g1 || g1 === 'h1') { + return g2; + } + if (g1 === 'pre') { + return `${g2}`; + } + const parser = new ExpensiMark(); + if (parser.containsNonPairTag(g2)) { + return `<${g1}>${g2}`; + } + return `<${g1}>${g2}`; + }); +} + +/** + * Get the title for a thread based on parent message. + * If render in html, only the first line of the message should display. + */ +function getThreadReportName(parentReportAction: OnyxEntry | EmptyObject = {}, parentReportID?: string, shouldRenderAsHTML = false, shouldRenderFirstLineOnly = true): string { + if (isEmptyObject(parentReportAction)) { + return ''; + } + if (parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { + return Localize.translateLocal('iou.heldExpense'); + } + if (parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { + return Localize.translateLocal('iou.unheldExpense'); + } + if (ReportActionsUtils.isApprovedOrSubmittedReportAction(parentReportAction)) { + return ReportActionsUtils.getReportActionMessageText(parentReportAction); + } + if (ReportActionsUtils.isReimbursementQueuedAction(parentReportAction)) { + return getReimbursementQueuedActionMessage(parentReportAction, getReport(parentReportID), false); + } + if (!shouldRenderAsHTML && !shouldRenderFirstLineOnly) { + return Str.removeSMSDomain(parentReportAction?.message?.[0]?.text ?? ''); + } + + const threadReportNameHtml = getThreadReportNameHtml(parentReportAction?.message?.[0]?.html ?? ''); + + if (!shouldRenderAsHTML && shouldRenderFirstLineOnly) { + return lodashUnescape(Str.stripHTML(threadReportNameHtml)); + } + + return StringUtils.containsHtml(threadReportNameHtml) ? `${threadReportNameHtml}` : lodashUnescape(threadReportNameHtml); +} + /** * Get the title for a report. */ -function getReportName(report: OnyxEntry, policy: OnyxEntry = null): string { +function getReportName(report: OnyxEntry, policy: OnyxEntry = null, shouldRenderAsHTML = false, shouldRenderFirstLineOnly = false): string { let formattedName: string | undefined; const parentReportAction = ReportActionsUtils.getParentReportAction(report); if (isChatThread(report)) { @@ -3160,7 +3196,7 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu } const isAttachment = ReportActionsUtils.isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : null); - const parentReportActionMessage = getReportActionMessage(parentReportAction, report?.parentReportID).replace(/(\r\n|\n|\r)/gm, ' '); + const parentReportActionMessage = getThreadReportName(parentReportAction, report?.parentReportID, shouldRenderAsHTML, shouldRenderFirstLineOnly).replace(/(\r\n|\n|\r)/gm, ' '); if (isAttachment && parentReportActionMessage) { return `[${Localize.translateLocal('common.attachment')}]`; } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index b4ae942547a1..36323cd1e362 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -420,7 +420,7 @@ function getOptionData({ result.phoneNumber = personalDetail?.phoneNumber; } - const reportName = ReportUtils.getReportName(report, policy); + const reportName = ReportUtils.getReportName(report, policy, false, true); result.text = reportName; result.subtitle = subtitle; diff --git a/src/libs/StringUtils.ts b/src/libs/StringUtils.ts index 94cd04046ccc..76e7bb43b9ea 100644 --- a/src/libs/StringUtils.ts +++ b/src/libs/StringUtils.ts @@ -1,6 +1,15 @@ import _ from 'lodash'; import CONST from '@src/CONST'; +/** + * Check if the text contains HTML + * @param text + * @return whether the text contains HTML + */ +function containsHtml(text: string): boolean { + return /<\/?[a-z][\s\S]*>/i.test(text); +} + /** * Removes diacritical marks and non-alphabetic and non-latin characters from a string. * @param str - The input string to be sanitized. @@ -89,4 +98,4 @@ function getAcronym(string: string): string { return acronym; } -export default {sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeCRLF, getAcronym}; +export default {containsHtml, sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeCRLF, getAcronym}; diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 7e8ff07f1a53..67422c0e0492 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -106,7 +106,7 @@ function HeaderView({ const isTaskReport = ReportUtils.isTaskReport(report); const reportHeaderData = !isTaskReport && !isChatThread && report.parentReportID ? parentReport : report; // Use sorted display names for the title for group chats on native small screen widths - const title = ReportUtils.getReportName(reportHeaderData); + const title = ReportUtils.getReportName(reportHeaderData, undefined, true); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); const isConcierge = ReportUtils.isConciergeChatReport(report); diff --git a/src/styles/index.ts b/src/styles/index.ts index f12b3856de9d..bd6be3a2c7ea 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2003,6 +2003,11 @@ const styles = (theme: ThemeColors) => ...wordBreak.breakWord, }, + renderHTMLThreadTitle: { + display: 'flex', + flexDirection: 'row', + }, + renderHTMLTitle: { color: theme.text, fontSize: variables.fontSizeNormal,