diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 45501bf46374..81f43c73551e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -310,11 +310,13 @@ const ROUTES = { }, ATTACHMENTS: { route: 'attachment', - getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number, isAuthTokenRequired?: boolean) => { + getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number, isAuthTokenRequired?: boolean, attachmentLink?: string) => { const reportParam = reportID ? `&reportID=${reportID}` : ''; const accountParam = accountID ? `&accountID=${accountID}` : ''; const authTokenParam = isAuthTokenRequired ? '&isAuthTokenRequired=true' : ''; - return `attachment?source=${encodeURIComponent(url)}&type=${type}${reportParam}${accountParam}${authTokenParam}` as const; + const attachmentLinkParam = attachmentLink ? `&attachmentLink=${attachmentLink}` : ''; + + return `attachment?source=${encodeURIComponent(url)}&type=${type}${reportParam}${accountParam}${authTokenParam}${attachmentLinkParam}` as const; }, }, REPORT_PARTICIPANTS: { diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx index 4c470858292c..d31dcb5df7b6 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx @@ -17,7 +17,7 @@ import type {BaseAnchorForCommentsOnlyProps, LinkProps} from './types'; /* * This is a default anchor component for regular links. */ -function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '', target = '', children = null, style, onPress, ...rest}: BaseAnchorForCommentsOnlyProps) { +function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '', target = '', children = null, style, onPress, isLinkHasImage, ...rest}: BaseAnchorForCommentsOnlyProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const linkRef = useRef(null); @@ -62,7 +62,7 @@ function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '', role={CONST.ROLE.LINK} accessibilityLabel={href} > - + void; + + /** Indicates whether an image is wrapped in an anchor (``) tag with an `href` link */ + isLinkHasImage?: boolean; }; type BaseAnchorForCommentsOnlyProps = AnchorForCommentsOnlyProps & { diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 0bc233812ca7..685b208b061c 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -134,6 +134,8 @@ type AttachmentModalProps = { canEditReceipt?: boolean; shouldDisableSendButton?: boolean; + + attachmentLink?: string; }; function AttachmentModal({ @@ -161,6 +163,7 @@ function AttachmentModal({ type = undefined, accountID = undefined, shouldDisableSendButton = false, + attachmentLink = '', }: AttachmentModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -185,6 +188,7 @@ function AttachmentModal({ const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const transactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [currentAttachmentLink, setCurrentAttachmentLink] = useState(''); const [file, setFile] = useState( originalFileName @@ -211,6 +215,7 @@ function AttachmentModal({ setFile(attachment.file); setIsAuthTokenRequiredState(attachment.isAuthTokenRequired ?? false); onCarouselAttachmentChange(attachment); + setCurrentAttachmentLink(attachment?.attachmentLink ?? ''); }, [onCarouselAttachmentChange], ); @@ -482,6 +487,22 @@ function AttachmentModal({ const submitRef = useRef(null); + const getSubTitleLink = useMemo(() => { + if (shouldShowNotFoundPage) { + return ''; + } + + if (!isEmptyObject(report) && !isReceiptAttachment) { + return currentAttachmentLink; + } + + if (!isAuthTokenRequired && attachmentLink) { + return attachmentLink; + } + + return ''; + }, [shouldShowNotFoundPage, report, isReceiptAttachment, currentAttachmentLink, isAuthTokenRequired, attachmentLink]); + return ( <> {isLoading && } diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 81ee6d08934b..52ec56345f80 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -28,8 +28,13 @@ function extractAttachments( // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position. const uniqueSources = new Set(); + let currentLink = ''; + const htmlParser = new HtmlParser({ onopentag: (name, attribs) => { + if (name === 'a' && attribs.href) { + currentLink = attribs.href; + } if (name === 'video') { const source = tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); if (uniqueSources.has(source)) { @@ -81,9 +86,17 @@ function extractAttachments( file: {name: fileName, width, height}, isReceipt: false, hasBeenFlagged: attribs['data-flagged'] === 'true', + attachmentLink: currentLink, }); } }, + onclosetag: (name) => { + if (name !== 'a' || !currentLink) { + return; + } + + currentLink = ''; + }, }); if (type === CONST.ATTACHMENT_TYPE.NOTE) { diff --git a/src/components/Attachments/types.ts b/src/components/Attachments/types.ts index 8bac4cc53af6..88b8420745eb 100644 --- a/src/components/Attachments/types.ts +++ b/src/components/Attachments/types.ts @@ -28,6 +28,8 @@ type Attachment = { isReceipt?: boolean; duration?: number; + + attachmentLink?: string; }; export type {AttachmentSource, Attachment}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 122db1e7877b..1af172d07eea 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -30,6 +30,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { const internalNewExpensifyPath = Link.getInternalNewExpensifyPath(attrHref); const internalExpensifyPath = Link.getInternalExpensifyPath(attrHref); const isVideo = attrHref && Str.isVideo(attrHref); + const isLinkHasImage = tnode.tagName === 'a' && tnode.children.some((child) => child.tagName === 'img'); const isDeleted = HTMLEngineUtils.isDeletedNode(tnode); const textDecorationLineStyle = isDeleted ? styles.underlineLineThrough : {}; @@ -73,6 +74,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { 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} + isLinkHasImage={isLinkHasImage} > { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 86a7a9cabcb6..960d5647127b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,10 +1,11 @@ import type {ReactNode} from 'react'; import React, {useMemo} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import {View} from 'react-native'; +import {Linking, View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import EnvironmentBadge from './EnvironmentBadge'; import Text from './Text'; +import TextLink from './TextLink'; type HeaderProps = { /** Title of the Header */ @@ -21,9 +22,12 @@ type HeaderProps = { /** Additional header container styles */ containerStyles?: StyleProp; + + /** The URL link associated with the attachment's subtitle, if available */ + subTitleLink?: string; }; -function Header({title = '', subtitle = '', textStyles = [], containerStyles = [], shouldShowEnvironmentBadge = false}: HeaderProps) { +function Header({title = '', subtitle = '', textStyles = [], containerStyles = [], shouldShowEnvironmentBadge = false, subTitleLink = ''}: HeaderProps) { const styles = useThemeStyles(); const renderedSubtitle = useMemo( () => ( @@ -43,6 +47,22 @@ function Header({title = '', subtitle = '', textStyles = [], containerStyles = [ ), [subtitle, styles], ); + + const renderedSubTitleLink = useMemo( + () => ( + { + Linking.openURL(subTitleLink); + }} + numberOfLines={1} + style={styles.label} + > + {subTitleLink} + + ), + [styles.label, subTitleLink], + ); + return ( @@ -57,6 +77,7 @@ function Header({title = '', subtitle = '', textStyles = [], containerStyles = [ ) : title} {renderedSubtitle} + {!!subTitleLink && renderedSubTitleLink} {shouldShowEnvironmentBadge && } diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 0d307aa8728d..2c07c48d52b7 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -65,6 +65,7 @@ function HeaderWithBackButton({ shouldDisplaySearchRouter = false, progressBarPercentage, style, + subTitleLink = '', }: HeaderWithBackButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -108,10 +109,12 @@ function HeaderWithBackButton({ title={title} subtitle={stepCounter ? translate('stepCounter', stepCounter) : subtitle} textStyles={[titleColor ? StyleUtils.getTextColorStyle(titleColor) : {}, isCentralPaneSettings && styles.textHeadlineH2]} + subTitleLink={subTitleLink} /> ); }, [ StyleUtils, + subTitleLink, isCentralPaneSettings, policy, progressBarPercentage, diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 12dc1aa9684b..6eef2b072eee 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -142,6 +142,9 @@ type HeaderWithBackButtonProps = Partial & { /** Additional styles to add to the component */ style?: StyleProp; + + /** The URL link associated with the attachment's subtitle, if available */ + subTitleLink?: string; }; export type {ThreeDotsMenuItem}; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3eae46ac2855..ee368e15de98 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1549,6 +1549,7 @@ type AuthScreensParamList = CentralPaneScreensParamList & type: ValueOf; accountID: string; isAuthTokenRequired?: string; + attachmentLink?: string; }; [SCREENS.PROFILE_AVATAR]: { accountID: string; diff --git a/src/pages/home/report/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx index d30d8e9aabc1..2fdec372c610 100644 --- a/src/pages/home/report/ReportAttachments.tsx +++ b/src/pages/home/report/ReportAttachments.tsx @@ -18,6 +18,7 @@ function ReportAttachments({route}: ReportAttachmentsProps) { const type = route.params.type; const accountID = route.params.accountID; const isAuthTokenRequired = route.params.isAuthTokenRequired; + const attachmentLink = route.params.attachmentLink; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); @@ -26,10 +27,10 @@ function ReportAttachments({route}: ReportAttachmentsProps) { const onCarouselAttachmentChange = useCallback( (attachment: Attachment) => { - const routeToNavigate = ROUTES.ATTACHMENTS.getRoute(reportID, type, String(attachment.source), Number(accountID)); + const routeToNavigate = ROUTES.ATTACHMENTS.getRoute(reportID, type, String(attachment.source), Number(accountID), attachment.isAuthTokenRequired, attachment.attachmentLink); Navigation.navigate(routeToNavigate); }, - [reportID, accountID, type], + [reportID, type, accountID], ); return ( @@ -48,6 +49,7 @@ function ReportAttachments({route}: ReportAttachmentsProps) { onCarouselAttachmentChange={onCarouselAttachmentChange} shouldShowNotFoundPage={!isLoadingApp && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID} isAuthTokenRequired={!!isAuthTokenRequired} + attachmentLink={attachmentLink ?? ''} /> ); }