From 3694a3612dcb2d29caceb940aa6a8d08de754ea7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 7 Jul 2026 00:08:51 -0400 Subject: [PATCH] =?UTF-8?q?fix(embeds):=20second-pass=20review=20=E2=80=94?= =?UTF-8?q?=20Reddit=20auto-height,=20sandbox,=20Vimeo/Shorts=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the second review pass (agents inspected providers' live embed scripts): - FIX Reddit auto-height (was broken): embed.reddit.com posts { type:'resize.embed', data: } — height is under 'data', not 'height', so it never resized and clipped taller posts (scrolling=no). Add that shape. - Align TweetEmbed sandbox with EMBED_SANDBOX (adds allow-popups-to-escape-sandbox) so links/login popups opened from inside a tweet aren't crippled. - Vimeo: resolve channel/group/album video forms (vimeo.com/channels/{n}/{id} etc.), not just paths starting with the id. - Mobile Shorts: m.youtube.com/shorts/{id} now renders portrait 9:16, not landscape. - Move extractEmbedHeight into videoEmbed.ts and unit-test all three resize shapes (Instagram MEASURE / Reddit resize.embed / Twitter twttr.private.resize). Agents confirmed everything else current & robust (Dailymotion geo host, Tidal gridify, sandbox tokens are a safe superset, X still on platform.twitter.com, 550px cap). Tests 21 in this suite. Co-Authored-By: Claude Opus 4.8 --- .../components/url-preview/UrlPreviewCard.tsx | 33 +------------ src/app/utils/videoEmbed.test.ts | 23 ++++++++- src/app/utils/videoEmbed.ts | 48 +++++++++++++++++-- 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index c8cdc6fc8..c0e000ec8 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -20,6 +20,7 @@ import { onEnterOrSpace } from '../../utils/keyboard'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { + extractEmbedHeight, getTikTokVideoId, getTweetId, isTikTokLink, @@ -566,36 +567,6 @@ const EMBED_MAX_HEIGHT = 1400; 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. @@ -644,7 +615,7 @@ function TweetEmbed({ id }: { id: string }) { )}&theme=${theme}&dnt=true`} title="Post on X" style={{ height: `${height}px` }} - sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" + sandbox={EMBED_SANDBOX} allow="autoplay; encrypted-media; picture-in-picture; fullscreen" loading="lazy" scrolling="no" diff --git a/src/app/utils/videoEmbed.test.ts b/src/app/utils/videoEmbed.test.ts index 4b0462ef9..2ead5a849 100644 --- a/src/app/utils/videoEmbed.test.ts +++ b/src/app/utils/videoEmbed.test.ts @@ -6,6 +6,7 @@ import { isYouTubeShorts, getVimeoVideoId, getVimeoParts, + extractEmbedHeight, getTikTokVideoId, isTikTokLink, tiktokIdFromOembed, @@ -39,7 +40,7 @@ test('YouTube: watch / youtu.be / embed / shorts', () => { assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123'); }); -test('Vimeo (incl. unlisted hash)', () => { +test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => { assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789'); assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null); assert.deepEqual(getVimeoParts('https://vimeo.com/123456789/abc123'), { @@ -47,6 +48,26 @@ test('Vimeo (incl. unlisted hash)', () => { hash: 'abc123', }); assert.ok(parseMediaEmbed('https://vimeo.com/123456789/abc123', 'h')?.embedUrl.includes('h=abc123')); + // channel / group / album share a trailing numeric video id + assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871'); + assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345'); + assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321'); +}); + +test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => { + assert.equal(extractEmbedHeight({ type: 'MEASURE', details: { height: 640 } }), 640); + assert.equal(extractEmbedHeight({ type: 'resize.embed', data: 812 }), 812); // Reddit + assert.equal( + extractEmbedHeight({ 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }] }), + 500, + ); + assert.equal(extractEmbedHeight({ height: 300 }), 300); // generic fallback + assert.equal(extractEmbedHeight({ type: 'other' }), undefined); + assert.equal(extractEmbedHeight('not-an-object'), undefined); +}); + +test('mobile Shorts (m.youtube.com) → portrait', () => { + assert.equal(parseMediaEmbed('https://m.youtube.com/shorts/abc123', 'h')?.kind, 'portrait'); }); test('TikTok: canonical /video/ only', () => { diff --git a/src/app/utils/videoEmbed.ts b/src/app/utils/videoEmbed.ts index 7db45787a..5912f840e 100644 --- a/src/app/utils/videoEmbed.ts +++ b/src/app/utils/videoEmbed.ts @@ -41,7 +41,7 @@ export function getYouTubeVideoId(url: string): string | null { export function isYouTubeShorts(url: string): boolean { try { const { hostname, pathname } = new URL(url); - if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false; + if (!YOUTUBE_HOSTS.includes(hostname)) return false; return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname); } catch { return false; @@ -51,7 +51,7 @@ export function isYouTubeShorts(url: string): boolean { export function getYoutubeShortsId(url: string): string | null { try { const { hostname, pathname } = new URL(url); - if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null; + if (!YOUTUBE_HOSTS.includes(hostname)) return null; const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); return m ? m[1] : null; } catch { @@ -66,8 +66,13 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null try { const { hostname, pathname } = new URL(url); if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null; - const m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/); - return m ? { id: m[1], hash: m[2] } : null; + // Canonical /{id} or unlisted /{id}/{hash} + let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/); + if (m) return { id: m[1], hash: m[2] }; + // channels/groups/album share a trailing numeric video id + m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/); + if (m) return { id: m[1] }; + return null; } catch { return null; } @@ -122,6 +127,41 @@ export function tiktokIdFromOembed(data: { return null; } +/** + * Read a content height out of the postMessage shapes used by the resizable + * embeds. Shapes verified against the providers' live 2026 embed scripts: + * Instagram: { type: 'MEASURE', details: { height } } + * Reddit: { type: 'resize.embed', data: } + * Twitter/X: { 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height }] }] } + */ +export function extractEmbedHeight(data: unknown): number | undefined { + if (!data || typeof data !== 'object') return undefined; + const d = data as { + type?: string; + height?: number; + data?: unknown; + details?: { height?: number }; + 'twttr.embed'?: unknown; + }; + if (d.type === 'MEASURE' && typeof d.details?.height === 'number') return d.details.height; + if (d.type === 'resize.embed' && typeof d.data === 'number') return d.data; + 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; + } + } + } + if (typeof d.height === 'number') return d.height; + return undefined; +} + export function tiktokPlayerEmbedUrl(id: string): string { // Pure 9:16 video player. NOTE: music_info/description are intentionally OFF — // they switch TikTok to a wide "video + info panel" layout that leaves empty