nexo/apps/mattermost/app/components/remove_markdown/index.tsx

235 lines
7.4 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Parser} from 'commonmark';
import Renderer from 'commonmark-react-renderer';
import React, {type ReactElement, type ReactNode, useCallback, useMemo, useRef, useState} from 'react';
import {type NativeSyntheticEvent, type StyleProp, Text, type TextLayoutEventData, type TextStyle, View} from 'react-native';
import Emoji from '@components/emoji';
import {useTheme} from '@context/theme';
import {computeTextStyle, getMarkdownTextStyles} from '@utils/markdown';
import ChannelMention from '../markdown/channel_mention';
import AtMention from './at_mention';
import type {MarkdownBaseRenderer, MarkdownChannelMentionRenderer, MarkdownEmojiRenderer} from '@typings/global/markdown';
type Props = {
enableEmoji?: boolean;
enableCodeSpan?: boolean;
enableHardBreak?: boolean;
enableSoftBreak?: boolean;
enableChannelLink?: boolean;
baseStyle?: StyleProp<TextStyle>;
numberOfLines?: number;
separateHeading?: boolean;
value: string;
};
const filterHeadingContext = (context: string[]) => context.filter((c) => !c.startsWith('heading'));
type HeadingBlockProps = {
children: ReactNode;
numberOfLines: number;
onLineCount: (count: number) => void;
};
const HeadingBlock = ({children, numberOfLines, onLineCount}: HeadingBlockProps) => {
const handleTextLayout = useCallback((e: NativeSyntheticEvent<TextLayoutEventData>) => {
if (numberOfLines > 0) {
onLineCount(Math.min(e.nativeEvent.lines.length, numberOfLines));
}
}, [numberOfLines, onLineCount]);
return (
<Text
numberOfLines={numberOfLines || undefined}
onTextLayout={numberOfLines > 0 ? handleTextLayout : undefined}
>
{children}
</Text>
);
};
const RemoveMarkdown = ({
enableEmoji,
enableChannelLink,
enableHardBreak,
enableSoftBreak,
enableCodeSpan,
baseStyle,
numberOfLines,
separateHeading,
value,
}: Props) => {
const theme = useTheme();
const textStyle = getMarkdownTextStyles(theme);
const [headingLineCount, setHeadingLineCount] = useState(0);
const renderText = useCallback(({literal}: {literal: string}) => {
return <Text style={baseStyle}>{literal}</Text>;
}, [baseStyle]);
const renderEmoji = useCallback(({emojiName, literal}: MarkdownEmojiRenderer) => {
if (!enableEmoji) {
return renderText({literal});
}
return (
<Emoji
emojiName={emojiName}
literal={literal}
testID='markdown_emoji'
textStyle={baseStyle}
/>
);
}, [baseStyle, enableEmoji, renderText]);
const renderBreak = useCallback(() => {
return '\n';
}, []);
const renderAtMention = useCallback(({context, mentionName}: {context: string[]; mentionName: string}) => {
return (
<AtMention
textStyle={computeTextStyle(textStyle, baseStyle, filterHeadingContext(context))}
mentionName={mentionName}
/>
);
}, [baseStyle, textStyle]);
const renderChannelLink = useCallback(({context, channelName}: MarkdownChannelMentionRenderer) => {
if (enableChannelLink) {
return (
<ChannelMention
linkStyle={textStyle.link}
textStyle={computeTextStyle(textStyle, baseStyle, filterHeadingContext(context))}
channelName={channelName}
/>
);
}
return renderText({literal: `~${channelName}`});
}, [baseStyle, enableChannelLink, renderText, textStyle]);
const renderCodeSpan = useCallback(({context, literal}: MarkdownBaseRenderer) => {
if (!enableCodeSpan) {
return renderText({literal});
}
const {code} = textStyle;
return (
<Text
style={computeTextStyle(textStyle, [baseStyle, code], context)}
testID='markdown_code_span'
>
{literal}
</Text>
);
}, [enableCodeSpan, textStyle, baseStyle, renderText]);
const renderHeading = useCallback(({children}: {children: ReactNode}) => {
if (separateHeading) {
return (
<HeadingBlock
numberOfLines={numberOfLines ?? 0}
onLineCount={setHeadingLineCount}
>
{children}
</HeadingBlock>
);
}
return <Text>{children}{'\n'}</Text>;
}, [numberOfLines, separateHeading]);
const paragraphLines = numberOfLines ? Math.max(0, numberOfLines - headingLineCount) : 0;
const renderParagraph = useCallback(({children, first}: {children: ReactNode; first: boolean}) => {
if (separateHeading) {
if (!first || (numberOfLines && paragraphLines === 0)) {
return null;
}
return <Text numberOfLines={paragraphLines || undefined}>{children}</Text>;
}
return <>{children}</>;
}, [numberOfLines, paragraphLines, separateHeading]);
const renderNull = () => {
return null;
};
const createRenderer = () => {
return new Renderer({
renderers: {
text: renderText,
emph: Renderer.forwardChildren,
strong: Renderer.forwardChildren,
del: Renderer.forwardChildren,
code: renderCodeSpan,
link: Renderer.forwardChildren,
image: renderNull,
atMention: renderAtMention,
channelLink: renderChannelLink,
emoji: renderEmoji,
hashtag: Renderer.forwardChildren,
latexinline: Renderer.forwardChildren,
paragraph: renderParagraph,
heading: renderHeading,
codeBlock: renderNull,
blockQuote: renderNull,
list: renderNull,
item: renderNull,
hardBreak: enableHardBreak ? renderBreak : renderNull,
thematicBreak: renderNull,
softBreak: enableSoftBreak ? renderBreak : renderNull,
htmlBlock: renderNull,
htmlInline: renderNull,
table: renderNull,
table_row: renderNull,
table_cell: renderNull,
mention_highlight: Renderer.forwardChildren,
editedIndicator: Renderer.forwardChildren,
} as any,
});
};
// 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();
}
const parser = parserRef.current;
const renderer = useMemo(createRenderer, [
renderText,
renderCodeSpan,
renderAtMention,
renderChannelLink,
renderEmoji,
renderHeading,
renderParagraph,
enableHardBreak,
renderBreak,
enableSoftBreak,
]);
const ast = parser.parse(value);
const rendered = renderer.render(ast) as ReactElement;
if (separateHeading) {
return <View>{rendered}</View>;
}
return rendered;
};
export default RemoveMarkdown;