feat(embeds): apply review findings + fix TikTok portrait padding
From the review-agent audit:
- Dailymotion: move off the Sept-2024-deprecated /embed/video path to
geo.dailymotion.com/player.html.
- Reddit: point at embed.reddit.com (www.redditmedia.com now 301s there).
- Vimeo: parse the unlisted hash (vimeo.com/{id}/{hash}) and pass &h=…, add dnt=1.
- Tidal: layout=gridify + ~275px height for albums/playlists (fixes narrow player).
- YouTube/Shorts: playsinline=1 (iOS keeps playback inline); parse /live/ +
music.youtube.com.
- Apple Music: /music-video/ renders 16:9 instead of a fixed audio height.
- Re-add a minimal sandbox to all media iframes (omits allow-top-navigation →
blocks phishing redirects) — defense-in-depth atop the CSP frame-src allowlist.
- Self-resize Instagram + Reddit post embeds via a shared useIframeAutoHeight hook
(also now covers the Tweet embed; matches platform.x.com origin too); drop the
fixed 720/480 heights. Cap tweet/post columns at ~550px, centered.
Also from user feedback: TikTok portrait player dropped music_info/description,
which forced TikTok's wide 'video + info panel' layout and left empty space
beside the video — now a clean 9:16 player that fills the box.
Tests 726 pass. CSP frame-src gains embed.reddit.com (separate desktop commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -553,14 +553,81 @@ function TikTokCard({
|
||||
// Card 3: Twitter / X
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Interactive X/Twitter post embed. The official platform.twitter.com iframe
|
||||
// renders the full post — playable video/GIF, image galleries, quote tweets —
|
||||
// and reports its height back to us via postMessage, which we apply so the card
|
||||
// grows to fit. Scoped to messages from OUR iframe + the platform.twitter.com
|
||||
// origin. Only mounted after the user clicks "View post" (privacy facade).
|
||||
// Origins allowed to drive a self-resizing embed (stable refs → effect deps).
|
||||
const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
|
||||
const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
|
||||
const REDDIT_ORIGINS = ['https://embed.reddit.com'];
|
||||
const NO_RESIZE_ORIGINS: string[] = [];
|
||||
const EMBED_MAX_HEIGHT = 1400;
|
||||
|
||||
// Defense-in-depth on top of the CSP frame-src allowlist. Critically OMITS
|
||||
// allow-top-navigation, so a compromised embed can't redirect the whole app
|
||||
// (phishing). Fullscreen still works — it's gated by allow=, not a sandbox token.
|
||||
const EMBED_SANDBOX =
|
||||
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation';
|
||||
|
||||
// Pull a content height out of the various providers' postMessage shapes.
|
||||
function extractEmbedHeight(data: unknown): number | undefined {
|
||||
if (!data || typeof data !== 'object') return undefined;
|
||||
const d = data as {
|
||||
type?: string;
|
||||
height?: number;
|
||||
details?: { height?: number };
|
||||
'twttr.embed'?: unknown;
|
||||
};
|
||||
// Instagram: { type: 'MEASURE', details: { height } }
|
||||
if (d.type === 'MEASURE' && typeof d.details?.height === 'number') return d.details.height;
|
||||
// Twitter/X: { 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height }] }] }
|
||||
const tw = d['twttr.embed'];
|
||||
if (tw) {
|
||||
const calls = (Array.isArray(tw) ? tw : [tw]) as Array<{
|
||||
method?: string;
|
||||
params?: Array<{ height?: number }>;
|
||||
}>;
|
||||
for (let i = 0; i < calls.length; i += 1) {
|
||||
const c = calls[i];
|
||||
if (c?.method === 'twttr.private.resize' && typeof c.params?.[0]?.height === 'number') {
|
||||
return c.params[0].height;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reddit / generic: { height }
|
||||
if (typeof d.height === 'number') return d.height;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Variable-height iframe embeds (Twitter/Instagram/Reddit) report their content
|
||||
// height via postMessage; listen — scoped to the allowed origins + OUR iframe —
|
||||
// and grow to fit. `origins` must be a stable reference.
|
||||
function useIframeAutoHeight(origins: string[], initial: number) {
|
||||
const ref = useRef<HTMLIFrameElement | null>(null);
|
||||
const [height, setHeight] = useState(initial);
|
||||
useEffect(() => {
|
||||
if (origins.length === 0) return undefined;
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (!origins.includes(e.origin)) return;
|
||||
if (ref.current && e.source !== ref.current.contentWindow) return;
|
||||
let data: unknown = e.data;
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const h = extractEmbedHeight(data);
|
||||
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), EMBED_MAX_HEIGHT));
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [origins]);
|
||||
return { ref, height };
|
||||
}
|
||||
|
||||
// Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets.
|
||||
// Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade).
|
||||
function TweetEmbed({ id }: { id: string }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [height, setHeight] = useState(320);
|
||||
const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
|
||||
const theme =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
@@ -568,32 +635,9 @@ function TweetEmbed({ id }: { id: string }) {
|
||||
? 'light'
|
||||
: 'dark';
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.origin !== 'https://platform.twitter.com') return;
|
||||
if (iframeRef.current && e.source !== iframeRef.current.contentWindow) return;
|
||||
let payload: unknown = e.data;
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const embed = (payload as { 'twttr.embed'?: unknown } | null)?.['twttr.embed'];
|
||||
const calls = Array.isArray(embed) ? embed : embed ? [embed] : [];
|
||||
calls.forEach((c: { method?: string; params?: Array<{ height?: number }> }) => {
|
||||
const h = c?.method === 'twttr.private.resize' ? c.params?.[0]?.height : undefined;
|
||||
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), 1400));
|
||||
});
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
ref={ref}
|
||||
className={previewCss.EmbedIframeStatic}
|
||||
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
|
||||
id,
|
||||
@@ -1036,7 +1080,18 @@ function MediaEmbedCard({
|
||||
const rawDescription = prev['og:description'] ?? '';
|
||||
const portrait = embed.kind === 'portrait';
|
||||
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
|
||||
const rich = embed.kind === 'rich';
|
||||
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
|
||||
// Instagram/Reddit report their height via postMessage → self-resize.
|
||||
const resizeOrigins = !rich
|
||||
? NO_RESIZE_ORIGINS
|
||||
: embed.provider === 'instagram'
|
||||
? INSTAGRAM_ORIGINS
|
||||
: REDDIT_ORIGINS;
|
||||
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
||||
resizeOrigins,
|
||||
embed.height ?? 480,
|
||||
);
|
||||
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
|
||||
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
|
||||
const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
|
||||
@@ -1087,41 +1142,56 @@ function MediaEmbedCard({
|
||||
|
||||
return (
|
||||
<Box direction="Column">
|
||||
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
|
||||
{playing ? (
|
||||
// No sandbox: these are CSP-allowlisted trusted hosts, and sandboxing
|
||||
// breaks player features (fullscreen, TikTok's flow, etc.).
|
||||
<iframe
|
||||
className={previewCss.EmbedIframe}
|
||||
src={embed.embedUrl}
|
||||
title={title || badge.label}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
) : inlineMediaEmbeds ? (
|
||||
<button
|
||||
type="button"
|
||||
className={previewCss.EmbedFacade}
|
||||
onClick={() => setPlaying(true)}
|
||||
aria-label={`Play: ${title || badge.label}`}
|
||||
>
|
||||
{thumb}
|
||||
{playOverlay}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.EmbedFacade}
|
||||
aria-label={`Open on ${badge.label}: ${title}`}
|
||||
>
|
||||
{thumb}
|
||||
{playOverlay}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{playing && rich ? (
|
||||
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
|
||||
<iframe
|
||||
ref={resizeRef}
|
||||
className={previewCss.EmbedIframeStatic}
|
||||
src={embed.embedUrl}
|
||||
title={title || badge.label}
|
||||
style={{ height: `${resizeHeight}px` }}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
sandbox={EMBED_SANDBOX}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
scrolling="no"
|
||||
/>
|
||||
) : (
|
||||
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
|
||||
{playing ? (
|
||||
<iframe
|
||||
className={previewCss.EmbedIframe}
|
||||
src={embed.embedUrl}
|
||||
title={title || badge.label}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
sandbox={EMBED_SANDBOX}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
) : inlineMediaEmbeds ? (
|
||||
<button
|
||||
type="button"
|
||||
className={previewCss.EmbedFacade}
|
||||
onClick={() => setPlaying(true)}
|
||||
aria-label={`Play: ${title || badge.label}`}
|
||||
>
|
||||
{thumb}
|
||||
{playOverlay}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.EmbedFacade}
|
||||
aria-label={`Open on ${badge.label}: ${title}`}
|
||||
>
|
||||
{thumb}
|
||||
{playOverlay}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||
<Text
|
||||
@@ -1229,6 +1299,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
src={tiktokPlayerEmbedUrl(videoId)}
|
||||
title={title || 'TikTok'}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
sandbox={EMBED_SANDBOX}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user