mattermost-mobile-upstream/app/components/markdown/markdown_link/markdown_link.tsx
Joram Wilander 6ec7bef334
MM-70688 Autolink phone numbers in posts as tel: links (#10143)
* Add phone number matching helpers for tel: autolinks.

Detect E.164, grouped national numbers, and tel: URIs while skipping
bare digit runs that look like ticket IDs or dates.

Co-authored-by: joram <joram@mattermost.com>

* Autolink phone numbers in post markdown as tel: links.

Wrap recognized numbers in markdown link nodes after parsing so tapping
opens the device dialer. Skip code, existing links, and unsafe posts.

Co-authored-by: joram <joram@mattermost.com>

* Copy phone numbers from tel: markdown links on long-press.

Treat tel: like mailto: in link and channel-header sheets, and cover
opening tel: URLs through the existing openLink path.

Co-authored-by: joram <joram@mattermost.com>

* Reject truncated phone matches inside longer digit sequences.

Skip past hyphens and dots when checking match boundaries so overlong
numbers and numeric prefixes are not autolinked. Keep spaces and
parentheses as boundaries so adjacent numbers still match.

Also address CodeRabbit test nits: assert match counts, prefix
autolinkPhoneNumbers cases with should, and assert rendered tel: links.

Co-authored-by: joram <joram@mattermost.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 14:32:17 -04:00

163 lines
5.2 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import Clipboard from '@react-native-clipboard/clipboard';
import React, {Children, type ReactElement, useCallback, useMemo} from 'react';
import {useIntl, defineMessages} from 'react-intl';
import {StyleSheet, Text, View} from 'react-native';
import urlParse from 'url-parse';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {useServerUrl} from '@context/server';
import {usePreventDoubleTap} from '@hooks/utils';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint, isEmail} from '@utils/helpers';
import {isTelHref} from '@utils/phone_number';
import {openLink} from '@utils/url/links';
type MarkdownLinkProps = {
children: ReactElement;
experimentalNormalizeMarkdownLinks: string;
href: string;
siteURL: string;
onLinkLongPress?: (url?: string) => void;
}
const messages = defineMessages({
copyEmail: {
id: 'mobile.markdown.link.copy_email',
defaultMessage: 'Copy Email Address',
},
copyPhone: {
id: 'mobile.markdown.link.copy_phone',
defaultMessage: 'Copy Phone Number',
},
copyURL: {
id: 'mobile.markdown.link.copy_url',
defaultMessage: 'Copy URL',
},
});
const style = StyleSheet.create({
bottomSheet: {
flex: 1,
},
});
const parseLinkLiteral = (literal: string) => {
let nextLiteral = literal;
const WWW_REGEX = /\b^(?:www.)/i;
if (nextLiteral.match(WWW_REGEX)) {
nextLiteral = literal.replace(WWW_REGEX, 'www.');
}
const parsed = urlParse(nextLiteral, {});
return parsed.href;
};
function getCopyLinkMessage(href: string) {
if (isTelHref(href)) {
return messages.copyPhone;
}
if (isEmail(href.replace(/^(mailto:|tel:)/i, ''))) {
return messages.copyEmail;
}
return messages.copyURL;
}
const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteURL, onLinkLongPress}: MarkdownLinkProps) => {
const intl = useIntl();
const managedConfig = useManagedConfig<ManagedConfig>();
const serverUrl = useServerUrl();
const handlePress = usePreventDoubleTap(useCallback(() => {
openLink(href, serverUrl, siteURL ?? '', intl);
}, [href, intl, serverUrl, siteURL]));
const handleLongPress = useCallback(() => {
if (managedConfig?.copyAndPasteProtection !== 'true') {
if (onLinkLongPress) {
onLinkLongPress(href);
return;
}
const cleanHref = href.replace(/^(mailto:|tel:)/i, '');
const copyMessage = getCopyLinkMessage(href);
const renderContent = () => {
return (
<View
testID='at_mention.bottom_sheet'
style={style.bottomSheet}
>
<SlideUpPanelItem
leftIcon='content-copy'
onPress={() => {
dismissBottomSheet();
Clipboard.setString(cleanHref);
}}
testID='at_mention.bottom_sheet.copy_url'
text={intl.formatMessage(copyMessage)}
/>
<SlideUpPanelItem
destructive={true}
leftIcon='cancel'
onPress={() => {
dismissBottomSheet();
}}
testID='at_mention.bottom_sheet.cancel'
text={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
/>
</View>
);
};
bottomSheet(renderContent, [1, bottomSheetSnapPoint(2, ITEM_HEIGHT)]);
}
}, [managedConfig?.copyAndPasteProtection, onLinkLongPress, href, intl]);
const renderedChildren = useMemo(() => {
if (!experimentalNormalizeMarkdownLinks) {
return children;
}
return Children.map(children, (c) => {
const child = c as ReactElement<any>;
if (!child.props.literal || typeof child.props.literal !== 'string' || (child.props.context && child.props.context.length && !child.props.context.includes('link'))) {
return child;
}
const {props, ...otherChildProps} = child;
const {literal, ...otherProps} = props;
const nextProps = {
literal: parseLinkLiteral(literal),
...otherProps,
};
return {
props: nextProps,
...otherChildProps,
};
});
}, [children, experimentalNormalizeMarkdownLinks]);
return (
<Text
onPress={handlePress}
onLongPress={handleLongPress}
testID='markdown_link'
>
{renderedChildren}
</Text>
);
};
export default MarkdownLink;