T-HUB/packages/safe-content/src/react.tsx
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

135 lines
5.7 KiB
TypeScript

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 <span className={props.className}>{props.children}</span>;
return (
<a href={safe.href} rel={EXTERNAL_LINK_REL} target="_blank" referrerPolicy="no-referrer" className={props.className}>
{props.children ?? safe.host}
</a>
);
}
function Emoji({ node, mediaProxy }: { node: EmojiNode; mediaProxy: MediaProxyPolicy }): ReactNode {
const label = `:${node.shortcode}:`;
if (!isProxiedMediaUrl(node.src, mediaProxy)) return label;
return (
<img
src={node.src}
alt={label}
title={label}
className="sc-emoji"
draggable={false}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
);
}
function leaf(node: TextNode | EmojiNode, key: number, mediaProxy: MediaProxyPolicy): ReactNode {
return node.type === "text" ? node.text : <Emoji key={key} node={node} mediaProxy={mediaProxy} />;
}
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 <br key={key} />;
case "emoji":
return <Emoji key={key} node={node} mediaProxy={mediaProxy} />;
case "link":
return (
<span key={key} className="sc-link">
<SafeLink href={node.href}>{node.children.map((c, i) => leaf(c, i, mediaProxy))}</SafeLink>
{/* The real destination host is always visible when the label could mislead. */}
{labelStartsWithHost(node.children, node.host) ? null : <span className="sc-link-host"> ({node.host})</span>}
</span>
);
case "mention":
return node.href ? (
<SafeLink key={key} href={node.href} className="sc-mention">{`@${node.handle}`}</SafeLink>
) : (
<span key={key} className="sc-mention">{`@${node.handle}`}</span>
);
case "hashtag":
return node.href ? (
<SafeLink key={key} href={node.href} className="sc-hashtag">{`#${node.tag}`}</SafeLink>
) : (
<span key={key} className="sc-hashtag">{`#${node.tag}`}</span>
);
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 (
<div className={props.className} dir="auto" data-truncated={doc.truncated ? "true" : undefined}>
{doc.children.map((p, i) => (
<p key={i} dir="auto">
{p.children.map((n, j) => inline(n, j, mediaProxy))}
</p>
))}
</div>
);
}
/** 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 (
<a href={preview.href} rel={EXTERNAL_LINK_REL} target="_blank" referrerPolicy="no-referrer" className={props.className}>
{preview.image && isProxiedMediaUrl(preview.image, mediaProxy) ? (
<img src={preview.image} alt="" loading="lazy" decoding="async" referrerPolicy="no-referrer" />
) : null}
{preview.siteName ? <span className="sc-preview-site" dir="auto">{preview.siteName}</span> : null}
{preview.title ? <strong className="sc-preview-title" dir="auto">{preview.title}</strong> : null}
{preview.description ? <span className="sc-preview-description" dir="auto">{preview.description}</span> : null}
<span className="sc-preview-host">{preview.host}</span>
</a>
);
}
/** 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 (
<bdi className={props.className} data-confusable={hasMixedScriptConfusables(name) ? "true" : undefined}>
{name}
</bdi>
);
}