mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 19:00:14 +00:00
* 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>
807 lines
27 KiB
TypeScript
807 lines
27 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
/* eslint-disable max-lines */
|
|
|
|
import {useManagedConfig} from '@mattermost/react-native-emm';
|
|
import {Parser, Node} from 'commonmark';
|
|
import Renderer from 'commonmark-react-renderer';
|
|
import React, {type ReactElement, useCallback, useMemo, useRef} from 'react';
|
|
import {Dimensions, type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native';
|
|
import performance from 'react-native-performance';
|
|
|
|
import CompassIcon from '@components/compass_icon';
|
|
import EditedIndicator from '@components/edited_indicator';
|
|
import Emoji from '@components/emoji';
|
|
import FormattedText from '@components/formatted_text';
|
|
import {useServerUrl} from '@context/server';
|
|
import {logError} from '@utils/log';
|
|
import {computeTextStyle, getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
|
import PostListPerformance from '@utils/performance/post_list_performance';
|
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
|
import {typography} from '@utils/typography';
|
|
import {getScheme} from '@utils/url';
|
|
|
|
import AtMention from './at_mention';
|
|
import ChannelMention from './channel_mention';
|
|
import Hashtag from './hashtag';
|
|
import InlineActionButton from './inline_action_button';
|
|
import InlineEntityLink, {type InlineEntityType} from './inline_entity_link';
|
|
import MarkdownBlockQuote from './markdown_block_quote';
|
|
import MarkdownCodeBlock from './markdown_code_block';
|
|
import MarkdownImage from './markdown_image';
|
|
import MarkdownLatexCodeBlock from './markdown_latex_block';
|
|
import MarkdownLatexInline from './markdown_latex_inline';
|
|
import MarkdownLink from './markdown_link';
|
|
import MarkdownList from './markdown_list';
|
|
import MarkdownListItem from './markdown_list_item';
|
|
import MarkdownTable from './markdown_table';
|
|
import MarkdownTableCell, {type MarkdownTableCellProps} from './markdown_table_cell';
|
|
import MarkdownTableImage from './markdown_table_image';
|
|
import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row';
|
|
import {addListItemIndices, autolinkPhoneNumbers, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, processInlineEntities, pullOutImages} from './transform';
|
|
|
|
import type {ChannelMentions} from './channel_mention/channel_mention';
|
|
import type {
|
|
MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer,
|
|
MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey, HighlightWithoutNotificationKey,
|
|
} from '@typings/global/markdown';
|
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
|
|
|
export type MarkdownProps = {
|
|
allowInlineActions?: boolean;
|
|
baseTextStyle: StyleProp<TextStyle>;
|
|
baseParagraphStyle?: StyleProp<TextStyle>;
|
|
channelId?: string;
|
|
channelMentions?: ChannelMentions;
|
|
disableAtChannelMentionHighlight?: boolean;
|
|
disableAtMentions?: boolean;
|
|
disableBlockQuote?: boolean;
|
|
disableChannelLink?: boolean;
|
|
disableCodeBlock?: boolean;
|
|
disableGallery?: boolean;
|
|
disableHashtags?: boolean;
|
|
disableLinks?: boolean;
|
|
disableHeading?: boolean;
|
|
disableQuotes?: boolean;
|
|
disableTables?: boolean;
|
|
enableLatex: boolean;
|
|
enableInlineLatex: boolean;
|
|
highlightKeys?: HighlightWithoutNotificationKey[];
|
|
imagesMetadata?: Record<string, PostImage | undefined>;
|
|
isEdited?: boolean;
|
|
isReplyPost?: boolean;
|
|
isSearchResult?: boolean;
|
|
layoutHeight?: number;
|
|
layoutWidth?: number;
|
|
location: AvailableScreens;
|
|
maxNodes: number;
|
|
mentionKeys?: UserMentionKey[];
|
|
minimumHashtagLength?: number;
|
|
postId?: string;
|
|
searchPatterns?: SearchPattern[];
|
|
theme: Theme;
|
|
value?: string;
|
|
onLinkLongPress?: (url?: string) => void;
|
|
|
|
/** Encrypted mm_blocks_actions cookie from post.props; used with allowInlineActions for mmaction:// links. */
|
|
mmBlocksActionCookie?: string;
|
|
|
|
/** integration_format for postActionWithCookie when mmBlocksActionCookie is set. */
|
|
integrationFormat?: PostActionIntegrationFormat;
|
|
isUnsafeLinksPost?: boolean;
|
|
}
|
|
|
|
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
|
return {
|
|
block: {
|
|
alignItems: 'flex-start',
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
},
|
|
errorMessage: {
|
|
color: theme.errorTextColor,
|
|
...typography('Body', 100),
|
|
},
|
|
maxNodesWarning: {
|
|
color: theme.errorTextColor,
|
|
},
|
|
atMentionOpacity: {
|
|
opacity: 1,
|
|
},
|
|
bold: {
|
|
fontWeight: '600',
|
|
},
|
|
};
|
|
});
|
|
|
|
const getExtraPropsForNode = (node: any) => {
|
|
const extraProps: Record<string, any> = {
|
|
continue: node.continue,
|
|
index: node.index,
|
|
};
|
|
|
|
if (node.type === 'image') {
|
|
extraProps.reactChildren = node.react.children;
|
|
extraProps.linkDestination = node.linkDestination;
|
|
extraProps.size = node.size;
|
|
}
|
|
|
|
if (node.type === 'checkbox') {
|
|
extraProps.isChecked = node.isChecked;
|
|
}
|
|
|
|
if (node.type === 'inline_entity_link') {
|
|
extraProps.entityType = node.entityType;
|
|
extraProps.entityId = node.entityId;
|
|
extraProps.linkUrl = node.linkUrl;
|
|
}
|
|
|
|
return extraProps;
|
|
};
|
|
|
|
const renderHashtagWithStyles = (
|
|
context: string[],
|
|
hashtag: string,
|
|
textStyles: MarkdownTextStyles,
|
|
baseTextStyle: StyleProp<TextStyle>,
|
|
) => {
|
|
const computedStyle = computeTextStyle(textStyles, baseTextStyle, context);
|
|
const linkStyle = [computedStyle, textStyles.link];
|
|
const headingIndex = context.findIndex((c) => c.includes('heading'));
|
|
if (headingIndex > -1) {
|
|
linkStyle.push(textStyles[context[headingIndex]]);
|
|
}
|
|
|
|
return (
|
|
<Hashtag
|
|
hashtag={hashtag}
|
|
linkStyle={linkStyle}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const Markdown = ({
|
|
allowInlineActions,
|
|
baseTextStyle,
|
|
channelId,
|
|
channelMentions,
|
|
disableAtChannelMentionHighlight,
|
|
disableAtMentions,
|
|
disableBlockQuote,
|
|
disableChannelLink,
|
|
disableCodeBlock,
|
|
disableGallery,
|
|
disableHashtags,
|
|
disableLinks,
|
|
disableHeading,
|
|
disableTables,
|
|
enableInlineLatex,
|
|
enableLatex,
|
|
maxNodes,
|
|
imagesMetadata,
|
|
isEdited,
|
|
isReplyPost,
|
|
isSearchResult,
|
|
layoutHeight,
|
|
layoutWidth,
|
|
location,
|
|
mentionKeys,
|
|
highlightKeys,
|
|
minimumHashtagLength = 3,
|
|
postId,
|
|
searchPatterns,
|
|
theme,
|
|
value = '',
|
|
baseParagraphStyle,
|
|
onLinkLongPress,
|
|
mmBlocksActionCookie,
|
|
integrationFormat,
|
|
isUnsafeLinksPost,
|
|
}: MarkdownProps) => {
|
|
const style = getStyleSheet(theme);
|
|
const blockStyles = useMemo<MarkdownBlockStyles>(() => getMarkdownBlockStyles(theme), [theme]);
|
|
const textStyles = useMemo<MarkdownTextStyles>(() => getMarkdownTextStyles(theme), [theme]);
|
|
const managedConfig = useManagedConfig<ManagedConfig>();
|
|
const serverUrl = useServerUrl();
|
|
|
|
const renderText = useCallback(({context, literal}: MarkdownBaseRenderer) => {
|
|
const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell');
|
|
if (context.indexOf('image') !== -1) {
|
|
// If this text is displayed, it will be styled by the image component
|
|
return (
|
|
<Text
|
|
testID='markdown_text'
|
|
selectable={selectable}
|
|
>
|
|
{literal}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
// Construct the text style based off of the parents of this node since RN's inheritance is limited
|
|
let styles: StyleProp<TextStyle>;
|
|
if (disableHeading) {
|
|
styles = computeTextStyle(textStyles, baseTextStyle, context.filter((c) => !c.startsWith('heading')));
|
|
} else {
|
|
styles = computeTextStyle(textStyles, baseTextStyle, context);
|
|
}
|
|
|
|
if (context.includes('mention_highlight')) {
|
|
styles = [styles, {backgroundColor: theme.mentionHighlightBg}];
|
|
}
|
|
|
|
return (
|
|
<Text
|
|
testID='markdown_text'
|
|
style={styles}
|
|
selectable={selectable}
|
|
>
|
|
{literal}
|
|
</Text>
|
|
);
|
|
}, [baseTextStyle, disableHeading, managedConfig.copyAndPasteProtection, textStyles, theme.mentionHighlightBg]);
|
|
|
|
const renderAtMention = useCallback(({context, mentionName}: MarkdownAtMentionRenderer) => {
|
|
if (disableAtMentions) {
|
|
return renderText({context, literal: `@${mentionName}`});
|
|
}
|
|
const computedStyles = StyleSheet.flatten(computeTextStyle(textStyles, baseTextStyle, context));
|
|
const {fontFamily, fontSize, fontWeight} = computedStyles;
|
|
|
|
return (
|
|
<AtMention
|
|
channelId={channelId}
|
|
disableAtChannelMentionHighlight={disableAtChannelMentionHighlight}
|
|
mentionStyle={[textStyles.mention, {fontSize, fontWeight, fontFamily}]}
|
|
textStyle={[computedStyles, style.atMentionOpacity]}
|
|
isSearchResult={isSearchResult}
|
|
location={location}
|
|
mentionName={mentionName}
|
|
mentionKeys={mentionKeys}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
}, [baseTextStyle, channelId, disableAtChannelMentionHighlight, disableAtMentions, isSearchResult, location, mentionKeys, renderText, style.atMentionOpacity, textStyles, theme]);
|
|
|
|
const renderBlockQuote = useCallback(({children, ...otherProps}: any) => {
|
|
if (disableBlockQuote) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<MarkdownBlockQuote
|
|
iconStyle={blockStyles?.quoteBlockIcon}
|
|
{...otherProps}
|
|
>
|
|
{children}
|
|
</MarkdownBlockQuote>
|
|
);
|
|
}, [disableBlockQuote, blockStyles?.quoteBlockIcon]);
|
|
|
|
const renderBreak = () => {
|
|
return <Text testID='markdown_break'>{'\n'}</Text>;
|
|
};
|
|
|
|
const renderChannelLink = useCallback(({context, channelName}: MarkdownChannelMentionRenderer) => {
|
|
if (disableChannelLink || isUnsafeLinksPost) {
|
|
return renderText({context, literal: `~${channelName}`});
|
|
}
|
|
|
|
return (
|
|
<ChannelMention
|
|
linkStyle={textStyles.link}
|
|
textStyle={computeTextStyle(textStyles, baseTextStyle, context)}
|
|
channelName={channelName}
|
|
channelMentions={channelMentions}
|
|
/>
|
|
);
|
|
}, [baseTextStyle, channelMentions, disableChannelLink, isUnsafeLinksPost, renderText, textStyles]);
|
|
|
|
const renderCheckbox = useCallback(({isChecked}: {isChecked: boolean}) => {
|
|
return (
|
|
<Text testID='markdown_checkbox'>
|
|
<CompassIcon
|
|
name={isChecked ? 'checkbox-marked' : 'checkbox-blank-outline'}
|
|
size={16}
|
|
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
|
/>
|
|
{' '}
|
|
</Text>
|
|
);
|
|
}, [theme.centerChannelColor]);
|
|
|
|
const renderInlineEntityLink = useCallback(({entityType, entityId, linkUrl}: {entityType: InlineEntityType; entityId: string; linkUrl?: string}) => {
|
|
if (!entityType || !entityId) {
|
|
return null;
|
|
}
|
|
return (
|
|
<InlineEntityLink
|
|
entityType={entityType}
|
|
entityId={entityId}
|
|
linkUrl={linkUrl}
|
|
/>
|
|
);
|
|
}, []);
|
|
|
|
const renderCodeBlock = useCallback((props: any) => {
|
|
if (disableCodeBlock) {
|
|
return null;
|
|
}
|
|
|
|
// These sometimes include a trailing newline
|
|
const content = props.literal.replace(/\n$/, '');
|
|
|
|
if (enableLatex && !isUnsafeLinksPost && props.language === 'latex') {
|
|
const baseFontSize = StyleSheet.flatten(baseTextStyle)?.fontSize;
|
|
return (
|
|
<MarkdownLatexCodeBlock
|
|
content={content}
|
|
theme={theme}
|
|
baseFontSize={baseFontSize}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MarkdownCodeBlock
|
|
content={content}
|
|
language={props.language}
|
|
textStyle={textStyles.codeBlock}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
}, [disableCodeBlock, enableLatex, isUnsafeLinksPost, baseTextStyle, textStyles.codeBlock, theme]);
|
|
|
|
const renderCodeSpan = useCallback(({context, literal}: MarkdownBaseRenderer) => {
|
|
const {code} = textStyles;
|
|
return (
|
|
<Text
|
|
style={computeTextStyle(textStyles, [baseTextStyle, code], context)}
|
|
testID='markdown_code_span'
|
|
>
|
|
{literal}
|
|
</Text>
|
|
);
|
|
}, [baseTextStyle, textStyles]);
|
|
|
|
const renderEditedIndicator = useCallback(({context}: {context: string[]}) => {
|
|
return (
|
|
<EditedIndicator
|
|
baseTextStyle={baseTextStyle}
|
|
theme={theme}
|
|
context={context}
|
|
iconSize={14}
|
|
checkHeadings={true}
|
|
testID='edited_indicator'
|
|
/>
|
|
);
|
|
}, [baseTextStyle, theme]);
|
|
|
|
const renderEmoji = useCallback(({context, emojiName, literal}: MarkdownEmojiRenderer) => {
|
|
return (
|
|
<Emoji
|
|
emojiName={emojiName}
|
|
literal={literal}
|
|
testID='markdown_emoji'
|
|
textStyle={computeTextStyle(textStyles, baseTextStyle, context)}
|
|
/>
|
|
);
|
|
}, [baseTextStyle, textStyles]);
|
|
|
|
const renderHashtag = useCallback(({context, hashtag}: {context: string[]; hashtag: string}) => {
|
|
if (disableHashtags || isUnsafeLinksPost) {
|
|
return renderText({context, literal: `#${hashtag}`});
|
|
}
|
|
|
|
return renderHashtagWithStyles(context, hashtag, textStyles, baseTextStyle);
|
|
}, [baseTextStyle, disableHashtags, isUnsafeLinksPost, renderText, textStyles]);
|
|
|
|
const renderHeading = useCallback(({children, level}: {children: ReactElement; level: string}) => {
|
|
if (disableHeading) {
|
|
return (
|
|
<Text
|
|
style={style.bold}
|
|
testID='markdown_heading'
|
|
>
|
|
{children}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
const containerStyle = [
|
|
style.block,
|
|
textStyles[`heading${level}`],
|
|
];
|
|
const textStyle = textStyles[`heading${level}Text`];
|
|
|
|
return (
|
|
<View
|
|
style={containerStyle as StyleProp<ViewStyle>}
|
|
testID='markdown_heading'
|
|
>
|
|
<Text style={textStyle}>
|
|
{children}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}, [disableHeading, style.block, style.bold, textStyles]);
|
|
|
|
const renderHtml = useCallback((props: any) => {
|
|
let rendered = renderText(props);
|
|
|
|
if (props.isBlock) {
|
|
rendered = (
|
|
<View
|
|
style={style.block}
|
|
testID='markdown_html'
|
|
>
|
|
{rendered}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return rendered;
|
|
}, [renderText, style.block]);
|
|
|
|
const renderImage = useCallback(({linkDestination, context, src, size}: MarkdownImageRenderer) => {
|
|
if (!imagesMetadata || isUnsafeLinksPost) {
|
|
return null;
|
|
}
|
|
|
|
const isInsideLink = context.indexOf('link') !== -1;
|
|
|
|
const disableInteraction = (disableGallery ?? Boolean(!location)) || isInsideLink;
|
|
|
|
if (context.indexOf('table') !== -1) {
|
|
// We have enough problems rendering images as is, so just render a link inside of a table
|
|
return (
|
|
<MarkdownTableImage
|
|
disabled={disableInteraction}
|
|
imagesMetadata={imagesMetadata}
|
|
location={location}
|
|
postId={postId!}
|
|
source={src}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MarkdownImage
|
|
disabled={disableInteraction}
|
|
errorTextStyle={[computeTextStyle(textStyles, baseTextStyle, context), textStyles.error]}
|
|
layoutHeight={layoutHeight}
|
|
layoutWidth={layoutWidth}
|
|
linkDestination={linkDestination}
|
|
imagesMetadata={imagesMetadata}
|
|
isReplyPost={isReplyPost}
|
|
location={location}
|
|
postId={postId!}
|
|
source={src}
|
|
sourceSize={size}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
}, [baseTextStyle, disableGallery, imagesMetadata, isReplyPost, isUnsafeLinksPost, layoutHeight, layoutWidth, location, postId, textStyles, theme]);
|
|
|
|
const renderLatexInline = useCallback(({context, latexCode}: MarkdownLatexRenderer) => {
|
|
if (!enableInlineLatex || isUnsafeLinksPost) {
|
|
return renderText({context, literal: `$${latexCode}$`});
|
|
}
|
|
|
|
const baseFontSize = StyleSheet.flatten(baseTextStyle)?.fontSize;
|
|
return (
|
|
<Text>
|
|
<MarkdownLatexInline
|
|
content={latexCode}
|
|
maxMathWidth={Dimensions.get('window').width * 0.75}
|
|
theme={theme}
|
|
baseFontSize={baseFontSize}
|
|
/>
|
|
</Text>
|
|
);
|
|
}, [baseTextStyle, enableInlineLatex, isUnsafeLinksPost, renderText, theme]);
|
|
|
|
const renderLink = useCallback(({children, href}: {children: ReactElement; href: string}) => {
|
|
if (isUnsafeLinksPost || disableLinks) {
|
|
return renderText({context: [], literal: href});
|
|
}
|
|
|
|
if (getScheme(href) === 'mmaction') {
|
|
if (allowInlineActions && postId) {
|
|
return (
|
|
<InlineActionButton
|
|
href={href}
|
|
postId={postId}
|
|
baseTextStyle={baseTextStyle}
|
|
mmBlocksActionCookie={mmBlocksActionCookie}
|
|
integrationFormat={integrationFormat}
|
|
>
|
|
{children}
|
|
</InlineActionButton>
|
|
);
|
|
}
|
|
return <Text>{children}</Text>;
|
|
}
|
|
|
|
return (
|
|
<MarkdownLink
|
|
href={href}
|
|
onLinkLongPress={onLinkLongPress}
|
|
>
|
|
{children}
|
|
</MarkdownLink>
|
|
);
|
|
}, [allowInlineActions, baseTextStyle, disableLinks, integrationFormat, isUnsafeLinksPost, mmBlocksActionCookie, onLinkLongPress, postId, renderText]);
|
|
|
|
const renderList = useCallback(({children, start, tight, type}: any) => {
|
|
return (
|
|
<MarkdownList
|
|
ordered={type !== 'bullet'}
|
|
start={start}
|
|
tight={tight}
|
|
>
|
|
{children}
|
|
</MarkdownList>
|
|
);
|
|
}, []);
|
|
|
|
const renderListItem = useCallback(({children, context, ...otherProps}: any) => {
|
|
const level = context.filter((type: string) => type === 'list').length;
|
|
|
|
return (
|
|
<MarkdownListItem
|
|
bulletStyle={baseTextStyle}
|
|
level={level}
|
|
{...otherProps}
|
|
>
|
|
{children}
|
|
</MarkdownListItem>
|
|
);
|
|
}, [baseTextStyle]);
|
|
|
|
const renderParagraph = useCallback(({children, first}: {children: ReactElement[]; first: boolean}) => {
|
|
if (!children || children.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const blockStyle: StyleProp<ViewStyle> = [style.block];
|
|
if (!first) {
|
|
blockStyle.push(blockStyles?.adjacentParagraph);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={blockStyle}
|
|
testID='markdown_paragraph'
|
|
>
|
|
<Text style={baseParagraphStyle}>
|
|
{children}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}, [baseParagraphStyle, blockStyles?.adjacentParagraph, style.block]);
|
|
|
|
const renderTable = useCallback(({children, numColumns}: {children: ReactElement; numColumns: number}) => {
|
|
if (disableTables) {
|
|
return null;
|
|
}
|
|
return (
|
|
<MarkdownTable numColumns={numColumns}>
|
|
{children}
|
|
</MarkdownTable>
|
|
);
|
|
}, [disableTables]);
|
|
|
|
const renderTableCell = useCallback((args: MarkdownTableCellProps) => {
|
|
return (
|
|
<MarkdownTableCell {...args}/>
|
|
);
|
|
}, []);
|
|
|
|
const renderTableRow = useCallback((args: MarkdownTableRowProps) => {
|
|
return (
|
|
<MarkdownTableRow {...args}/>
|
|
);
|
|
}, []);
|
|
|
|
const renderThematicBreak = useCallback(() => {
|
|
return (
|
|
<View
|
|
style={blockStyles?.horizontalRule}
|
|
testID='markdown_thematic_break'
|
|
/>
|
|
);
|
|
}, [blockStyles?.horizontalRule]);
|
|
|
|
const renderMaxNodesWarning = useCallback(() => {
|
|
const styles = [baseTextStyle, style.maxNodesWarning];
|
|
|
|
return (
|
|
<FormattedText
|
|
id='markdown.max_nodes.error'
|
|
defaultMessage='This message is too long to be shown fully on a mobile device. Please view it on desktop or contact an admin to increase this limit.'
|
|
style={styles}
|
|
testID='max_nodes_warning'
|
|
/>
|
|
);
|
|
}, [baseTextStyle, style.maxNodesWarning]);
|
|
|
|
const createRenderer = () => {
|
|
const renderers: any = {
|
|
text: renderText,
|
|
|
|
emph: Renderer.forwardChildren,
|
|
strong: Renderer.forwardChildren,
|
|
del: Renderer.forwardChildren,
|
|
code: renderCodeSpan,
|
|
link: renderLink,
|
|
image: renderImage,
|
|
atMention: renderAtMention,
|
|
channelLink: renderChannelLink,
|
|
emoji: renderEmoji,
|
|
hashtag: renderHashtag,
|
|
latexInline: renderLatexInline,
|
|
|
|
paragraph: renderParagraph,
|
|
heading: renderHeading,
|
|
codeBlock: renderCodeBlock,
|
|
blockQuote: renderBlockQuote,
|
|
|
|
list: renderList,
|
|
item: renderListItem,
|
|
|
|
hardBreak: renderBreak,
|
|
thematicBreak: renderThematicBreak,
|
|
softBreak: renderBreak,
|
|
|
|
htmlBlock: renderHtml,
|
|
htmlInline: renderHtml,
|
|
|
|
table: renderTable,
|
|
table_row: renderTableRow,
|
|
table_cell: renderTableCell,
|
|
|
|
mention_highlight: Renderer.forwardChildren,
|
|
search_highlight: Renderer.forwardChildren,
|
|
highlight_without_notification: Renderer.forwardChildren,
|
|
checkbox: renderCheckbox,
|
|
inline_entity_link: renderInlineEntityLink,
|
|
|
|
editedIndicator: renderEditedIndicator,
|
|
maxNodesWarning: renderMaxNodesWarning,
|
|
};
|
|
|
|
return new Renderer({
|
|
renderers,
|
|
renderParagraphsInLists: true,
|
|
maxNodes,
|
|
getExtraPropsForNode,
|
|
allowedTypes: Object.keys(renderers),
|
|
});
|
|
};
|
|
|
|
// Pattern suggested in https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents
|
|
const parserRef = useRef<Parser | null>(null);
|
|
if (parserRef.current === null) {
|
|
parserRef.current = new Parser({minimumHashtagLength});
|
|
}
|
|
const parser = parserRef.current;
|
|
|
|
const renderer = useMemo(createRenderer, [
|
|
renderText,
|
|
renderCodeSpan,
|
|
renderLink,
|
|
renderImage,
|
|
renderAtMention,
|
|
renderChannelLink,
|
|
renderEmoji,
|
|
renderHashtag,
|
|
renderLatexInline,
|
|
renderParagraph,
|
|
renderHeading,
|
|
renderCodeBlock,
|
|
renderBlockQuote,
|
|
renderList,
|
|
renderListItem,
|
|
renderThematicBreak,
|
|
renderHtml,
|
|
renderTable,
|
|
renderTableRow,
|
|
renderTableCell,
|
|
renderCheckbox,
|
|
renderInlineEntityLink,
|
|
renderEditedIndicator,
|
|
renderMaxNodesWarning,
|
|
maxNodes,
|
|
]);
|
|
|
|
const errorLogged = useRef(false);
|
|
|
|
const output = useMemo(() => {
|
|
const startTime = performance.now();
|
|
let ast;
|
|
try {
|
|
ast = parser.parse(value.toString());
|
|
|
|
ast = combineTextNodes(ast);
|
|
ast = addListItemIndices(ast);
|
|
ast = pullOutImages(ast);
|
|
ast = parseTaskLists(ast);
|
|
if (!disableLinks && !isUnsafeLinksPost) {
|
|
ast = autolinkPhoneNumbers(ast);
|
|
}
|
|
ast = processInlineEntities(ast, serverUrl);
|
|
if (mentionKeys) {
|
|
ast = highlightMentions(ast, mentionKeys);
|
|
}
|
|
if (highlightKeys) {
|
|
ast = highlightWithoutNotification(ast, highlightKeys);
|
|
}
|
|
if (searchPatterns) {
|
|
ast = highlightSearchPatterns(ast, searchPatterns);
|
|
}
|
|
|
|
if (isEdited) {
|
|
const editIndicatorNode = new Node('edited_indicator');
|
|
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
|
|
ast.lastChild.appendChild(editIndicatorNode);
|
|
} else {
|
|
const node = new Node('paragraph');
|
|
node.appendChild(editIndicatorNode);
|
|
|
|
ast.appendChild(node);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (!errorLogged.current) {
|
|
logError('An error occurred while parsing Markdown', e);
|
|
|
|
errorLogged.current = true;
|
|
}
|
|
|
|
return (
|
|
<FormattedText
|
|
id='markdown.parse_error'
|
|
defaultMessage='An error occurred while parsing this text'
|
|
style={style.errorMessage}
|
|
/>
|
|
);
|
|
}
|
|
|
|
try {
|
|
const generatedOutput = renderer.render(ast);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Track markdown parsing performance
|
|
if (channelId && postId && value) {
|
|
PostListPerformance.trackMarkdownParse(channelId, undefined, postId, value.length, duration);
|
|
}
|
|
|
|
return generatedOutput;
|
|
} catch (e) {
|
|
if (!errorLogged.current) {
|
|
logError('An error occurred while rendering Markdown', e);
|
|
|
|
errorLogged.current = true;
|
|
}
|
|
|
|
return (
|
|
<FormattedText
|
|
id='markdown.render_error'
|
|
defaultMessage='An error occurred while rendering this text'
|
|
style={style.errorMessage}
|
|
/>
|
|
);
|
|
}
|
|
}, [channelId, disableLinks, highlightKeys, isEdited, isUnsafeLinksPost, mentionKeys, parser, postId, renderer, searchPatterns, serverUrl, style.errorMessage, value]);
|
|
|
|
return output;
|
|
};
|
|
|
|
export const testExports = {
|
|
renderHashtagWithStyles,
|
|
};
|
|
|
|
export default Markdown;
|