import React, { PropsWithChildren, ReactNode, useCallback, useMemo } from 'react'; import { GestureResponderEvent, I18nManager, Linking, Platform, Text, TextProps, View, ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; // @ts-expect-error import Markdown from 'react-native-markdown-package'; import Animated, { clamp, scrollTo, useAnimatedRef, useSharedValue } from 'react-native-reanimated'; import { DefaultRules, defaultRules, MatchFunction, NodeOutput, Output, ParseFunction, parseInline, SingleASTNode, State, } from 'simple-markdown'; import type { LocalMessage, UserResponse } from 'stream-chat'; import { generateMarkdownText } from './generateMarkdownText'; import type { MessageContextValue } from '../../../../contexts/messageContext/MessageContext'; import type { MarkdownStyle } from '../../../../contexts/themeContext/utils/theme'; import { primitives } from '../../../../theme'; import { semantics } from '../../../../theme/generated/dark/StreamTokens'; import { escapeRegExp } from '../../../../utils/utils'; type ReactNodeOutput = NodeOutput; type ReactOutput = Output; export const MarkdownReactiveScrollView = ({ children }: { children: ReactNode }) => { const scrollViewRef = useAnimatedRef(); const contentWidth = useSharedValue(0); const visibleContentWidth = useSharedValue(0); const offsetBeforeScroll = useSharedValue(0); const panGesture = Gesture.Pan() .activeOffsetX([-5, 5]) .onUpdate((event) => { const { translationX } = event; scrollTo(scrollViewRef, offsetBeforeScroll.value - translationX, 0, false); }) .onEnd((event) => { const { translationX } = event; const velocityEffect = event.velocityX * 0.3; const finalPosition = clamp( offsetBeforeScroll.value - translationX - velocityEffect, 0, contentWidth.value - visibleContentWidth.value, ); offsetBeforeScroll.value = finalPosition; scrollTo(scrollViewRef, finalPosition, 0, true); }); return ( { contentWidth.value = width; }} onLayout={(e) => { visibleContentWidth.value = e.nativeEvent.layout.width; }} ref={scrollViewRef} scrollEnabled={false} > {children} ); }; const defaultMarkdownStyles: MarkdownStyle = { codeBlock: { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'Monospace', fontWeight: '500', marginVertical: primitives.spacingXs, fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, }, inlineCode: { padding: primitives.spacingXxs, paddingHorizontal: primitives.spacingXxs, fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, }, list: { marginBottom: primitives.spacingXs, marginTop: primitives.spacingXs, }, listItemNumber: { fontWeight: 'bold', }, listItemText: { flex: 0, }, listRow: { flexDirection: 'row', }, mentions: { fontWeight: '700', fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, }, paragraph: { marginBottom: primitives.spacingXs, fontSize: primitives.typographyFontSizeMd, marginTop: primitives.spacingXs, }, paragraphCenter: { marginBottom: primitives.spacingXs, fontSize: primitives.typographyFontSizeMd, marginTop: primitives.spacingXs, }, paragraphWithImage: { marginBottom: primitives.spacingXs, marginTop: primitives.spacingXs, }, table: { borderRadius: primitives.radiusXxs, borderWidth: 1, flex: 1, flexDirection: 'row', }, tableHeader: { flexDirection: 'row', justifyContent: 'space-around', }, tableHeaderCell: { fontWeight: '500', }, tableRow: { alignItems: 'center', justifyContent: 'space-around', }, tableRowCell: { flex: 1, }, }; const mentionsParseFunction: ParseFunction = (capture, parse, state) => ({ content: parseInline(parse, capture[0], state), }); export type MarkdownRules = Partial; export type RenderTextParams = Partial< Pick > & { semantics: typeof semantics; message: LocalMessage; markdownRules?: MarkdownRules; markdownStyles?: MarkdownStyle; messageOverlay?: boolean; messageTextNumberOfLines?: number; onLink?: (url: string) => Promise; onlyEmojis?: boolean; isMyMessage?: boolean; }; export const renderText = (params: RenderTextParams) => { const { semantics, markdownRules, markdownStyles, message, messageOverlay, messageTextNumberOfLines, onLink: onLinkParams, onLongPress: onLongPressParam, onlyEmojis, onPress: onPressParam, preventPress, isMyMessage, } = params; const { text } = message; const markdownText = generateMarkdownText(text); const styles: MarkdownStyle = { ...defaultMarkdownStyles, ...markdownStyles, paragraph: { ...(onlyEmojis ? {} : { lineHeight: primitives.typographyLineHeightNormal }), ...defaultMarkdownStyles.paragraph, ...markdownStyles?.paragraph, }, paragraphCenter: { ...(onlyEmojis ? {} : { lineHeight: primitives.typographyLineHeightNormal }), ...defaultMarkdownStyles.paragraphCenter, ...markdownStyles?.paragraphCenter, }, autolink: { fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, ...defaultMarkdownStyles.autolink, color: semantics.textLink, ...markdownStyles?.autolink, }, blockQuoteSection: { ...defaultMarkdownStyles.blockQuoteSection, flexDirection: 'row', padding: primitives.spacingXs, ...markdownStyles?.blockQuoteSection, }, blockQuoteSectionBar: { ...defaultMarkdownStyles.blockQuoteSectionBar, backgroundColor: semantics.borderCoreStrong, marginRight: primitives.spacingXs, width: 2, ...markdownStyles?.blockQuoteSectionBar, }, blockQuoteText: { fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, ...defaultMarkdownStyles.blockQuoteText, ...markdownStyles?.blockQuoteText, }, codeBlock: { ...defaultMarkdownStyles.codeBlock, backgroundColor: semantics.backgroundCoreSurfaceSubtle, color: semantics.textPrimary, padding: primitives.spacingXs, ...markdownStyles?.codeBlock, }, inlineCode: { ...defaultMarkdownStyles.inlineCode, backgroundColor: semantics.backgroundCoreSurfaceSubtle, borderColor: semantics.borderCoreSubtle, color: semantics.accentError, ...markdownStyles?.inlineCode, }, mentions: { ...defaultMarkdownStyles.mentions, color: semantics.accentPrimary, ...markdownStyles?.mentions, }, table: { ...defaultMarkdownStyles.table, borderColor: semantics.borderCoreStrong, marginVertical: primitives.spacingXs, ...markdownStyles?.table, }, tableHeader: { ...defaultMarkdownStyles.tableHeader, backgroundColor: semantics.backgroundCoreSurfaceSubtle, ...markdownStyles?.tableHeader, }, tableHeaderCell: { fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, ...defaultMarkdownStyles.tableHeaderCell, padding: primitives.spacingXxs, ...markdownStyles?.tableHeaderCell, }, tableRow: { ...defaultMarkdownStyles.tableRow, ...markdownStyles?.tableRow, }, tableRowCell: { ...defaultMarkdownStyles.tableRowCell, borderColor: semantics.borderCoreStrong, padding: primitives.spacingXxs, ...markdownStyles?.tableRowCell, }, tableRowLast: { ...markdownStyles?.tableRowLast, }, text: { fontSize: primitives.typographyFontSizeMd, ...(onlyEmojis ? {} : { lineHeight: primitives.typographyLineHeightNormal }), ...defaultMarkdownStyles.text, color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, ...markdownStyles?.text, writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', }, }; const onLink = (url: string) => onLinkParams ? onLinkParams(url) : Linking.canOpenURL(url).then((canOpenUrl) => canOpenUrl && Linking.openURL(url)); let previousLink: string | undefined; const linkReact: ReactNodeOutput = (node, output, { ...state }) => { let url: string; // Some long URLs with `&` separated parameters are trimmed and the url only until first param is taken. // This is done because of internal link been taken from the original URL in react-native-markdown-package. So, we check for `withinLink` and take the previous full URL. if (state?.withinLink && previousLink) { url = previousLink; } else { url = node.target; previousLink = node.target; } const onPress = (event: GestureResponderEvent) => { if (!preventPress && onPressParam) { onPressParam({ additionalInfo: { url }, defaultHandler: () => { onLink(url); }, emitter: 'textLink', event, }); } }; const onLongPress = (event: GestureResponderEvent) => { if (!preventPress && onLongPressParam) { onLongPressParam({ additionalInfo: { url }, emitter: 'textLink', event, }); } }; return ( {output(node.content, { ...state, withinLink: true })} ); }; const paragraphTextReact: ReactNodeOutput = (node, output, { ...state }) => { if (messageTextNumberOfLines !== undefined) { // If we want to truncate the message text, lets only truncate the first paragraph // and simply not render rest of the paragraphs. if (state.key === '0' || state.key === 0) { return ( {output(node.content, state)} ); } else { return null; } } return ( {output(node.content, state)} ); }; // take the @ mentions and turn them into markdown? // translate links const { mentioned_users } = message; const mentionedUsernames = (mentioned_users || []) .map((user) => user.name || user.id) .filter(Boolean) .sort((a, b) => b.length - a.length) .map(escapeRegExp); const mentionedUsers = mentionedUsernames.map((username) => `@${username}`).join('|'); const regEx = new RegExp(`^\\B(${mentionedUsers})`, 'g'); const mentionsMatchFunction: MatchFunction = (source) => regEx.exec(source); const mentionsReact: ReactNodeOutput = (node, output, { ...state }) => { /**removes the @ prefix of username */ const userName = node.content[0]?.content?.substring(1); const onPress = (event: GestureResponderEvent) => { if (!preventPress && onPressParam) { onPressParam({ additionalInfo: { user: mentioned_users?.find((user: UserResponse) => userName === user.name), }, emitter: 'textMention', event, }); } }; const onLongPress = (event: GestureResponderEvent) => { if (!preventPress && onLongPressParam) { onLongPressParam({ emitter: 'textMention', event, }); } }; return ( {Array.isArray(node.content) ? node.content.reduce((acc, current) => acc + current.content, '') || '' : output(node.content, state)} ); }; const listReact: ReactNodeOutput = (node, output, state) => ( ); const codeBlockReact: ReactNodeOutput = (node, _, state) => ( {node?.content?.trim()} ); const tableReact: ReactNodeOutput = (node, output, state) => ( ); const blockQuoteReact: ReactNodeOutput = (node, output, state) => ( {output(node.content, state)} ); const customRules = { blockQuote: { react: blockQuoteReact, }, codeBlock: { react: codeBlockReact }, // do not render images, we will scrape them out of the message and show on attachment card component image: { match: () => null }, link: { react: linkReact }, list: { react: listReact }, // Truncate long text content in the message overlay paragraph: messageTextNumberOfLines ? { react: paragraphTextReact } : {}, // we have no react rendering support for reflinks reflink: { match: () => null }, sublist: { react: listReact }, ...(mentionedUsers ? { mentions: { match: mentionsMatchFunction, order: defaultRules.text.order - 0.5, parse: mentionsParseFunction, react: mentionsReact, }, } : {}), table: { react: tableReact }, }; return ( {markdownText} ); }; export interface ListOutputProps { node: SingleASTNode; output: ReactOutput; state: State; styles?: Partial; } /** * For lists and sublists, the default behavior of the markdown library we use is * to always renumber any list, so all ordered lists start from 1. * * This custom rule overrides this behavior both for top level lists and sublists, * in order to start the numbering from the number of the first list item provided. */ export const ListOutput = ({ node, output, state, styles }: ListOutputProps) => { let isSublist = state.withinList; const parentTypes = ['text', 'paragraph', 'strong']; return ( {node.items.map((item: SingleASTNode, index: number) => { const indexAfterStart = node.start + index; if (item === null) { return ( ); } isSublist = item.length > 1 && item[1].type === 'list'; const isSublistWithinText = parentTypes.includes((item[0] ?? {}).type) && isSublist; const style = isSublistWithinText ? { marginBottom: 0 } : {}; return ( {output(item, state)} ); })} ); }; interface BulletProps extends TextProps { index?: number; } const Bullet = ({ index, style }: BulletProps) => ( {index ? `${index}. ` : '\u2022 '} ); const ListRow = ({ children, style }: PropsWithChildren) => ( {children} ); const ListItem = ({ children, style }: PropsWithChildren) => ( {children} ); export type MarkdownTableProps = { node: SingleASTNode; output: ReactOutput; state: State; styles: Partial; }; const transpose = (matrix: SingleASTNode[][]) => matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex])); const MarkdownTable = ({ node, output, state, styles }: MarkdownTableProps) => { const content = useMemo(() => { const nodeContent = [node?.header, ...(node?.cells ?? null)]; return transpose(nodeContent); }, [node?.cells, node?.header]); const columns = content?.map((column, idx) => ( )); return ( {columns} ); }; export type MarkdownTableRowProps = { items: SingleASTNode[]; output: ReactOutput; state: State; styles: Partial; }; const MarkdownTableColumn = ({ items, output, state, styles }: MarkdownTableRowProps) => { const [headerCellContent, ...columnCellContents] = items; const ColumnCell = useCallback( ({ content }: { content: SingleASTNode }) => content ? ( {output(content, state)} ) : null, [output, state, styles], ); return ( {headerCellContent ? ( {output(headerCellContent, state)} ) : null} {columnCellContents && columnCellContents.map((content, idx) => ( ))} ); };