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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
135
packages/safe-content/src/react.tsx
Normal file
135
packages/safe-content/src/react.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue