import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Image, Linking, Pressable, ScrollView, Text, View } from 'react-native'; import Markdown, { MarkdownIt, type ASTNode, type RenderRule } from 'react-native-markdown-display'; import { markdownStyles } from './markdownStyles'; import { decodeFragment, extractTableData, getNodeText, hasWideContent, isSafeUrl, slugify } from './markdownTextUtils'; import { getTextDirection } from './messageUtils'; import { useMarkdownScroll } from './markdownScrollContext'; import { useSuperagentTheme } from '../../theme'; import type { SuperagentMarkdownRenderer, SuperagentMessage } from '../../types'; // CommonMark + GFM (tables, strikethrough, autolinks). `html: false` keeps raw // HTML out, `linkify` makes bare http(s) URLs tappable like the old renderer. const markdownItInstance = MarkdownIt({ breaks: false, html: false, linkify: true, typographer: false }); export function MarkdownText({ content, isUser, message, onLongPress, renderMarkdown, }: { content: string; isUser: boolean; message: SuperagentMessage; onLongPress?: () => void; renderMarkdown?: SuperagentMarkdownRenderer; }) { // Subscribe to the color scheme so the memoized style/rules below re-resolve // through the themed proxy when the user toggles light/dark. const { scheme } = useSuperagentTheme(); const scroller = useMarkdownScroll(); // slug -> heading view, so an in-document anchor link can scroll to its target. const headingRefs = useRef>(new Map()); const registerHeading = useCallback((slug: string, element: View | null) => { if (!slug) return; if (element) headingRefs.current.set(slug, element); else headingRefs.current.delete(slug); }, []); // GitHub-style disambiguation for repeated headings: the first "Setup" gets // `setup`, the next `setup-1`, etc., so anchor links resolve to distinct // targets. The counter is reset per render (below) so slugs stay stable and // ordinal, keeping headingRefs keyed on unique slugs. const slugCountsRef = useRef>(new Map()); const resolveHeadingSlug = useCallback((node: ASTNode) => { const base = slugify(getNodeText(node)); const seen = slugCountsRef.current.get(base) ?? 0; slugCountsRef.current.set(base, seen + 1); return seen === 0 ? base : `${base}-${seen}`; }, []); const handleLinkPress = useCallback((href: string) => { // In-document anchor (e.g. [jump](#setup)): scroll to the matching heading. // Slugify the (decoded) fragment so encoded hrefs like `#my%20heading` // resolve to the same slug headings are registered under. if (href.startsWith('#')) { const target = headingRefs.current.get(slugify(decodeFragment(href.slice(1)))); if (target) scroller?.scrollViewIntoView(target); return; } // External link: open only safe http(s) URLs. if (isSafeUrl(href)) Linking.openURL(href).catch(() => {}); }, [scroller]); const rules = useMemo( () => createMarkdownRules({ onLinkPress: handleLinkPress, onLongPress, registerHeading, resolveHeadingSlug }), [handleLinkPress, onLongPress, registerHeading, resolveHeadingSlug], ); // Rebuild the style object whenever the scheme changes (new identity) so the // library re-reads the themed proxy instead of caching the first scheme. const style = useMemo(() => { const resolved = { ...markdownStyles } as Record; // Align each message by the direction of its own text — RTL languages // (Hebrew/Arabic) right, LTR left — independent of the device locale. `body` // styles are inherited by the rendered text, so textAlign here reaches every // paragraph. const base = hasWideContent(content) ? resolved.bodyWide : resolved.body; const direction = getTextDirection(content); resolved.body = { ...(base as object), textAlign: direction === 'rtl' ? 'right' : 'left', writingDirection: direction, }; return resolved; // eslint-disable-next-line react-hooks/exhaustive-deps }, [content, scheme]); if (renderMarkdown) { const Renderer = renderMarkdown; return ; } if (!content || !content.trim()) return null; // Reset the heading-slug counter before the rules render this message's // headings (children render right after this body), so slugs are assigned // fresh and in document order on every render. slugCountsRef.current = new Map(); return ( {content} ); } // Override the few rules where we need native-only behavior the library's // defaults don't give us: long-press passthrough, link + in-document anchor // handling, heading anchors, horizontally scrollable code and tables, bold // table headers, and images. The default image rule renders via // react-native-fit-image, which throws "size props must be present none or both // of width and height" on this RN version — so render with RN's . function createMarkdownRules({ onLinkPress, onLongPress, registerHeading, resolveHeadingSlug, }: { onLinkPress: (href: string) => void; onLongPress?: () => void; registerHeading: (slug: string, element: View | null) => void; resolveHeadingSlug: (node: ASTNode) => string; }): Record { // Headings are wrapped in a measurable, ref'd View keyed by their (unique, // disambiguated) slug so anchor links can scroll to them. collapsable={false} // keeps Android from flattening the View away (which would null the ref). const heading = (level: number): RenderRule => (node, children, _parent, styles) => { const slug = resolveHeadingSlug(node); return ( registerHeading(slug, element)}> {children} ); }; return { // Skip onLongPress for text inside a link or blocklink, otherwise the leaf // becomes the touch responder and swallows the (block)link's onPress. text: (node, _children, parent, styles, inheritedStyles = {}) => { const insideLink = parent.some((ancestor) => ancestor.type === 'link' || ancestor.type === 'blocklink'); return ( {node.content} ); }, heading1: heading(1), heading2: heading(2), heading3: heading(3), heading4: heading(4), heading5: heading(5), heading6: heading(6), link: (node, children, _parent, styles) => ( onLinkPress(node.attributes?.href ?? '')} style={styles.link}> {children} ), // The library rewrites links wrapping block content (e.g. a linked image) // to `blocklink`. Route it through the same handler so block links honor the // safe-URL / anchor policy instead of the library default, which would open // any scheme (it also re-wraps children in styles.image — we don't). blocklink: (node, children, _parent) => ( onLinkPress(node.attributes?.href ?? '')}> {children} ), // Fenced code scrolls horizontally (the host drops the bubble Pressable for // ``` / ~~~). Indented code (code_block) is NOT flagged by the host, so it // renders without horizontal scroll to avoid a scroll/long-press conflict. fence: (node, _children, _parent, styles) => renderCodeBlock(node, styles, onLongPress, true), code_block: (node, _children, _parent, styles) => renderCodeBlock(node, styles, onLongPress, false), // Render the whole table from the AST so we can size each column to its // content (clamped) and keep cells on a single line — matching the original // hand-rolled renderer, which laid out tables better than the library's // flex-based default. table: (node, _children, _parent, styles) => ( ), // Only render safe http(s) image sources; skip data:/file:/other schemes // (matches the inline-link policy and the original hand-rolled renderer). image: (node, _children, _parent, styles) => { const url = node.attributes?.src ?? ''; if (!isSafeUrl(url)) return null; return ; }, }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function renderCodeBlock(node: ASTNode, styles: Record, onLongPress?: () => void, scrollable = true) { const code = node.content.replace(/\n$/, ''); const block = ( {code} ); if (!scrollable) return {block}; return ( {block} ); } // Extra horizontal space added to a column's measured text width: cell padding // (10 + 10) plus the 1px left border. Columns are capped so a very long cell // truncates instead of producing an enormous column. const CELL_HORIZONTAL_EXTRA = 21; const MAX_COLUMN_WIDTH = 340; // Ported from the original renderer (hand-laid table so it can scroll // horizontally), but columns are now sized to the *measured* width of their // widest cell instead of a char-count estimate: each cell renders at its // natural width first, onLayout reports it, and we apply the per-column max so // columns are exactly content-wide and aligned across rows. function MarkdownTable({ node, onLinkPress, styles, }: { node: ASTNode; onLinkPress: (href: string) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any styles: Record; }) { const { header, rows } = extractTableData(node); const columnCount = Math.max(header.length, ...rows.map((row) => row.length), 0); const [columnWidths, setColumnWidths] = useState([]); const maxWidthsRef = useRef([]); const recordCellWidth = useCallback((column: number, width: number) => { const next = Math.ceil(width); if ((maxWidthsRef.current[column] ?? 0) >= next) return; maxWidthsRef.current[column] = next; setColumnWidths([...maxWidthsRef.current]); }, []); // undefined while unmeasured -> the cell sizes to its content on first pass. const columnStyle = (column: number) => { const measured = columnWidths[column]; if (measured == null) return undefined; return { width: Math.min(measured + CELL_HORIZONTAL_EXTRA, MAX_COLUMN_WIDTH) }; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const renderCell = (cell: ASTNode | undefined, column: number, cellStyle: any, textStyle: any) => ( recordCellWidth(column, event.nativeEvent.layout.width)} style={textStyle} > {renderCellContent(cell, styles, onLinkPress)} ); return ( {Array.from({ length: columnCount }, (_unused, column) => renderCell(header[column], column, styles.th, styles.thText))} {rows.map((row, rowIndex) => ( {Array.from({ length: columnCount }, (_unused, column) => renderCell(row[column], column, styles.td, styles.tdText))} ))} ); } // Render a cell's inline content (bold / italic / strikethrough / code / links) // into spans that nest inside the single-line cell . function renderCellContent( node: ASTNode | undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any styles: Record, onLinkPress: (href: string) => void, ): React.ReactNode { if (!node) return ''; const children = () => (node.children ?? []).map((child, index) => ( {renderCellContent(child, styles, onLinkPress)} )); switch (node.type) { case 'text': return node.content; case 'softbreak': case 'hardbreak': return ' '; case 'code_inline': return {node.content}; case 'strong': return {children()}; case 'em': return {children()}; case 's': return {children()}; case 'link': return onLinkPress(node.attributes?.href ?? '')} style={styles.link}>{children()}; default: return children(); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any function MarkdownImage({ alt, style, url }: { alt: string; style: any; url: string }) { // Generated images have unknown dimensions; render full-width and lock the // aspect ratio once the natural size loads (falls back to 3:2 meanwhile). const [aspectRatio, setAspectRatio] = useState(null); return ( { const { height, width } = event.nativeEvent.source; if (width > 0 && height > 0) setAspectRatio(width / height); }} resizeMode="contain" source={{ uri: url }} style={[style, { aspectRatio: aspectRatio ?? 1.5 }]} /> ); }