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/.
135 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|