fix(embeds): second-pass review — Reddit auto-height, sandbox, Vimeo/Shorts gaps
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> } — 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 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { onEnterOrSpace } from '../../utils/keyboard';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
|
extractEmbedHeight,
|
||||||
getTikTokVideoId,
|
getTikTokVideoId,
|
||||||
getTweetId,
|
getTweetId,
|
||||||
isTikTokLink,
|
isTikTokLink,
|
||||||
@@ -566,36 +567,6 @@ const EMBED_MAX_HEIGHT = 1400;
|
|||||||
const EMBED_SANDBOX =
|
const EMBED_SANDBOX =
|
||||||
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation';
|
'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
|
// Variable-height iframe embeds (Twitter/Instagram/Reddit) report their content
|
||||||
// height via postMessage; listen — scoped to the allowed origins + OUR iframe —
|
// height via postMessage; listen — scoped to the allowed origins + OUR iframe —
|
||||||
// and grow to fit. `origins` must be a stable reference.
|
// and grow to fit. `origins` must be a stable reference.
|
||||||
@@ -644,7 +615,7 @@ function TweetEmbed({ id }: { id: string }) {
|
|||||||
)}&theme=${theme}&dnt=true`}
|
)}&theme=${theme}&dnt=true`}
|
||||||
title="Post on X"
|
title="Post on X"
|
||||||
style={{ height: `${height}px` }}
|
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"
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isYouTubeShorts,
|
isYouTubeShorts,
|
||||||
getVimeoVideoId,
|
getVimeoVideoId,
|
||||||
getVimeoParts,
|
getVimeoParts,
|
||||||
|
extractEmbedHeight,
|
||||||
getTikTokVideoId,
|
getTikTokVideoId,
|
||||||
isTikTokLink,
|
isTikTokLink,
|
||||||
tiktokIdFromOembed,
|
tiktokIdFromOembed,
|
||||||
@@ -39,7 +40,7 @@ test('YouTube: watch / youtu.be / embed / shorts', () => {
|
|||||||
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
|
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/123456789'), '123456789');
|
||||||
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
|
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
|
||||||
assert.deepEqual(getVimeoParts('https://vimeo.com/123456789/abc123'), {
|
assert.deepEqual(getVimeoParts('https://vimeo.com/123456789/abc123'), {
|
||||||
@@ -47,6 +48,26 @@ test('Vimeo (incl. unlisted hash)', () => {
|
|||||||
hash: 'abc123',
|
hash: 'abc123',
|
||||||
});
|
});
|
||||||
assert.ok(parseMediaEmbed('https://vimeo.com/123456789/abc123', 'h')?.embedUrl.includes('h=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/<id> only', () => {
|
test('TikTok: canonical /video/<id> only', () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function getYouTubeVideoId(url: string): string | null {
|
|||||||
export function isYouTubeShorts(url: string): boolean {
|
export function isYouTubeShorts(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
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);
|
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -51,7 +51,7 @@ export function isYouTubeShorts(url: string): boolean {
|
|||||||
export function getYoutubeShortsId(url: string): string | null {
|
export function getYoutubeShortsId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
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_-]+)/);
|
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -66,8 +66,13 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
|
|||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
const m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
// Canonical /{id} or unlisted /{id}/{hash}
|
||||||
return m ? { id: m[1], hash: m[2] } : null;
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -122,6 +127,41 @@ export function tiktokIdFromOembed(data: {
|
|||||||
return null;
|
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: <height> }
|
||||||
|
* 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 {
|
export function tiktokPlayerEmbedUrl(id: string): string {
|
||||||
// Pure 9:16 video player. NOTE: music_info/description are intentionally OFF —
|
// 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
|
// they switch TikTok to a wide "video + info panel" layout that leaves empty
|
||||||
|
|||||||
Reference in New Issue
Block a user