/** * Public Markdown component for the mobile app. Hybrid renderer: * * - Prose (paragraphs, headings, lists, quotes, tables, inline code, * links, mentions) → `EnrichedMarkdownText` (react-native-enriched- * markdown, native md4c → NSAttributedString / Spannable). One * instance per prose island. * - Fenced code blocks → in-house `CodeBlock` with Shiki syntax * highlighting, copy button, and horizontal scroll. Shares the * `github-light` / `github-dark` themes with web for byte-identical * palettes. * - Images → in-house `MarkdownImage` with expo-image + auto aspect * ratio + tap-to-lightbox dispatch. * * Why hybrid instead of pure enriched: enriched does not let us inject * React for any leaf node (issues #54, #232 — maintainer: "no custom * renderers, by design"), which would permanently lock out syntax * highlighting and tap-to-lightbox. The maintainer themselves * recommends this split in #246: "split them out and render with * another instance of enriched-markdown". * * Pipeline: * * content * ↓ preprocessMobileMarkdown legacy mention shortcodes + file * cards + HTML strip with `
` → * " \n" (canonical CommonMark hard * break) * ↓ splitMarkdown marked.lexer → segments[] * ↓ render per-segment prose / code / image components * * Mention chip note: mobile renders `mention://` links via enriched's * default link styling (brand-colored, underlined), matching web's * fallback behavior when no `renderMention` is provided * (`packages/ui/markdown/Markdown.tsx:173-178`). The avatar pill * variant only ever existed on web in specific contexts that supplied * a custom renderer — mobile doesn't lose anything that exists by * default elsewhere. */ import { useCallback, useMemo } from "react"; import { Linking, View } from "react-native"; import { router } from "expo-router"; import { EnrichedMarkdownText } from "react-native-enriched-markdown"; import type { Attachment } from "@multica/core/types"; import { useWorkspaceStore } from "@/data/workspace-store"; import { preprocessMobileMarkdown } from "./preprocess"; import { useMarkdownStyle } from "./markdown-style"; import { splitMarkdown } from "./split-markdown"; import { CodeBlock } from "./code-block"; import { MarkdownImage } from "./markdown-image"; interface Props { content: string; /** * Attachments scoped to the same record this content belongs to (issue, * comment's parent issue, chat message). Used to resolve `mc://file/` * image URIs to a real HTTPS `download_url` — without it, iOS image loader * doesn't understand the mc: scheme and the image fails to load. */ attachments?: Attachment[]; /** * When `false`, kills the UIKit-native long-press selection (the magnifier * + selection handles) inside both the enriched prose and the in-house * `` content. Required for surfaces that own a competing * `onLongPress` gesture — most notably the comment card, whose long-press * opens the comment action sheet. Without `selectable={false}` the OS * gesture and the Pressable both fire and the selection magnifier * appears on top of the action sheet (Element X PR #1584 documents the * identical bug in a Matrix client). * * Default is `true` for reader surfaces (issue description, chat message * bodies) where users expect to be able to select and copy text natively. */ selectable?: boolean; /** * Strip the trailing paragraph `marginBottom` AND tighten paragraph * `lineHeight` so the markdown sits flush AND visually centered inside * a bounded container (chat user bubble, badge, tooltip — anything that * already supplies its own vertical padding). * * Two adjustments because two separate offsets break vertical centering * in a small box: * * 1. `marginBottom: 0` — mirrors web's `[&>*:last-child]:mb-0` * neutralisation pattern in * `packages/views/chat/components/chat-message-list.tsx`. * * 2. `lineHeight: 20` (down from 24) — paragraph default is calibrated * for prose readability (`lineHeight/fontSize ≈ 1.71` for CJK * leading), which makes the line-box 10pt taller than the glyph. * Combined with PingFang SC's baseline at ~75-80% of the line-box * (see `markdown-style.ts:54-67` and the inline-code note at 184-200), * the empty space inside the line-box is asymmetric — visible as * the glyph sitting off-centre in a small `py-2` bubble. Tightening * to ~1.43 collapses the line-box close to the glyph and the * residual asymmetry becomes too small to read as misalignment. * Multi-line content in compact mode loses some inter-line breathing * room — acceptable for the common single-paragraph bubble case. * * enriched-markdown applies one paragraph style to every paragraph * (can't single out the last), so multi-paragraph content in compact mode * also loses the 12px inter-paragraph gap — a fair trade for the bubble * case. */ compact?: boolean; } export function Markdown({ content, attachments, selectable = true, compact = false, }: Props) { const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); const baseStyle = useMarkdownStyle(); const markdownStyle = useMemo( () => compact ? { ...baseStyle, paragraph: { ...baseStyle.paragraph, marginBottom: 0, lineHeight: 20, }, } : baseStyle, [baseStyle, compact], ); const segments = useMemo(() => { const processed = preprocessMobileMarkdown(content); return splitMarkdown(processed); }, [content]); const onLinkPress = useCallback( ({ url }: { url: string }) => { // `mention://` is an internal scheme — never hand it to the system. // No app is registered for it, so `Linking.openURL("mention://...")` // would surface iOS's "Cannot open URL" prompt or silently fail // (depending on iOS version). Handle every shape inline and ALWAYS // return without falling through to Linking. // // mention://issue/ → navigate to that issue detail // mention://member/ → no-op (no member profile screen yet) // mention://agent/ → no-op (no agent profile screen yet) // mention://squad/ → no-op (no squad profile screen yet) // mention://all/all → no-op (semantic only — "everyone") // anything malformed → no-op if (url.startsWith("mention://")) { const rest = url.slice("mention://".length); const slash = rest.indexOf("/"); if (slash < 0) return; const type = rest.slice(0, slash); const id = rest.slice(slash + 1); if (type === "issue" && id && wsSlug) { router.push(`/${wsSlug}/issue/${id}`); } return; } // Everything else — http(s), mailto, tel, app-scheme deep links — // hand off to the system. Linking.openURL throws if no app handles // the URL; the catch keeps a stray tap from crashing the screen. Linking.openURL(url).catch(() => { // Silent: failing loudly is worse than a no-op tap. }); }, [wsSlug], ); if (segments.length === 0) return null; return ( // `gap-3` enforces 12px between every segment sibling (prose ↔ code, // code ↔ code, image ↔ anything). Going through gap instead of // per-child marginVertical avoids a NativeWind 4 / Yoga quirk where // adjacent `marginVertical` siblings collapse closer than the sum // would suggest — `gap` is layout-level spacing that doesn't depend // on margin behaviour. {segments.map((seg, i) => { switch (seg.type) { case "prose": return ( ); case "code": return ( ); case "image": return ( ); } })} ); }