import type { ReactNode } from "react"; import type { EmojiNode, InlineNode, LinkPreview, RichTextDocument, TextNode } from "./ast.js"; import { neutralizeDisplayName, hasMixedScriptConfusables } from "./text.js"; import { DEFAULT_MEDIA_PROXY, isProxiedMediaUrl, parseSafeHttpUrl, type MediaProxyPolicy } from "./url.js"; import { coerceRichTextDocument, parseLinkPreview } from "./validate.js"; /* * The single reviewed renderer for provider content (proposal ยง4.5). Every * provider string reaches the DOM as a React text node; the only attributes * set from provider data are re-validated http(s) `href`s and media-proxy * `src`s. No dangerouslySetInnerHTML, no style, no event handlers. */ export const EXTERNAL_LINK_REL = "noopener noreferrer nofollow ugc"; type CommonProps = { readonly className?: string; readonly mediaProxy?: MediaProxyPolicy }; export function SafeLink(props: { readonly href: string; readonly children?: ReactNode; readonly className?: string }): ReactNode { const safe = parseSafeHttpUrl(props.href); if (!safe) return {props.children}; return ( {props.children ?? safe.host} ); } function Emoji({ node, mediaProxy }: { node: EmojiNode; mediaProxy: MediaProxyPolicy }): ReactNode { const label = `:${node.shortcode}:`; if (!isProxiedMediaUrl(node.src, mediaProxy)) return label; return ( {label} ); } function leaf(node: TextNode | EmojiNode, key: number, mediaProxy: MediaProxyPolicy): ReactNode { return node.type === "text" ? node.text : ; } function labelStartsWithHost(children: readonly (TextNode | EmojiNode)[], host: string): boolean { const label = children .map((c) => (c.type === "text" ? c.text : "")) .join("") .trim() .toLowerCase() .replace(/^https?:\/\//, "") .replace(/^www\./, ""); const h = host.toLowerCase().replace(/^www\./, ""); return label === h || label.startsWith(h + "/") || label.startsWith(h + "?") || label.startsWith(h + "#") || label.startsWith(h + ":"); } function inline(node: InlineNode, key: number, mediaProxy: MediaProxyPolicy): ReactNode { switch (node.type) { case "text": return node.text; case "linebreak": return
; case "emoji": return ; case "link": return ( {node.children.map((c, i) => leaf(c, i, mediaProxy))} {/* The real destination host is always visible when the label could mislead. */} {labelStartsWithHost(node.children, node.host) ? null : ({node.host})} ); case "mention": return node.href ? ( {`@${node.handle}`} ) : ( {`@${node.handle}`} ); case "hashtag": return node.href ? ( {`#${node.tag}`} ) : ( {`#${node.tag}`} ); default: return null; } } /** Renders a rich-text document. `doc` may be untrusted JSON; it is re-validated first. */ export function RichText(props: CommonProps & { readonly doc: RichTextDocument | unknown }): ReactNode { const mediaProxy = props.mediaProxy ?? DEFAULT_MEDIA_PROXY; const doc = coerceRichTextDocument(props.doc, { mediaProxy }); return (
{doc.children.map((p, i) => (

{p.children.map((n, j) => inline(n, j, mediaProxy))}

))}
); } /** Link-preview card. `preview` may be raw provider card data or a built `LinkPreview`. */ export function LinkPreviewCard(props: CommonProps & { readonly preview: LinkPreview | unknown }): ReactNode { const mediaProxy = props.mediaProxy ?? DEFAULT_MEDIA_PROXY; const preview = parseLinkPreview(props.preview, mediaProxy); if (!preview) return null; return ( {preview.image && isProxiedMediaUrl(preview.image, mediaProxy) ? ( ) : null} {preview.siteName ? {preview.siteName} : null} {preview.title ? {preview.title} : null} {preview.description ? {preview.description} : null} {preview.host} ); } /** Display names / usernames: bidi + zero-width neutralised, isolated, confusables flagged. */ export function DisplayName(props: { readonly name: unknown; readonly maxLength?: number; readonly className?: string }): ReactNode { const name = typeof props.name === "string" ? neutralizeDisplayName(props.name, props.maxLength) : ""; return ( {name} ); }