diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 65e1300ca..664ceffb2 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -270,11 +270,14 @@ export const EmbedIframe = style([ }, ]); -// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline). +// Variable-height iframe that sizes itself (X/Twitter, Instagram, Reddit posts — +// height set inline). Capped ~550px (X/IG native max) + centered in the wide card. export const EmbedIframeStatic = style([ DefaultReset, { width: '100%', + maxWidth: toRem(550), + margin: '0 auto', border: 0, display: 'block', }, diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 7f526e6b9..c8cdc6fc8 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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(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(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 (