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}${g1}>`;
+ });
+}
+
+/**
+ * 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,