fix(embeds): parsing over/under-match + broken thumbnails + wide layout

Bugs found by a 3-agent audit of the inline-embed system (core posture —
sandbox, postMessage origin+source, XSS, noreferrer, oEmbed — verified sound);
fixes reviewed by 2 agents on the staged diff (both SHIP).

Parsing (videoEmbed.ts, + tests):
- Twitch/Kick/SoundCloud/Streamable reserved-path exclusion — their own utility
  pages (twitch.tv/directory, kick.com/browse, soundcloud.com/discover/…,
  streamable.com/login, bare /videos) no longer render as broken player embeds.
- SoundCloud: `/<artist>/<tab>` profile-tab listings excluded; `/<artist>/sets/<slug>`
  real sets still detected.
- Vimeo: unlisted-hash capture constrained to lowercase-hex, so a normal video's
  trailing segment (/likes, /settings, a slug) isn't captured as a bogus `h=`
  param that Vimeo then rejects.

Rendering (UrlPreviewCard.tsx, RenderMessageContent.tsx):
- Spotify/Steam/Discord/IMDb route og:image through mxcUrlToHttp like every other
  card — a raw og:image is an mxc:// URI (broken <img> on standard Synapse) or an
  off-homeserver request that defeats the click-to-play facade.
- `wide` card class now follows the RESOLVED embed (incl. the og:url short-link
  fallback), so an og:url-resolved player gets the wide layout, not a cramped one.
- Twitter host detection (isTwitter/isTwitterTweet) aligned with getTweetId —
  mobile.twitter.com and legacy /statuses/ now route to the Twitter card/embed.
- De-dupe preview URLs so a message repeating a link doesn't render sibling
  cards with identical React keys.

Gates: tsc 0, eslint 0, prettier clean, 910 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 23:35:59 -04:00
co-authored by Claude Opus 4.8
parent f03c0ef960
commit f2673effe4
4 changed files with 186 additions and 33 deletions
+14
View File
@@ -57,6 +57,10 @@ test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
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');
// a normal video with a trailing sub-path segment must NOT capture it as a hash
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.hash, undefined);
assert.equal(getVimeoParts('https://vimeo.com/123456789/settings')?.hash, undefined);
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.id, '123456789');
});
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
@@ -113,6 +117,7 @@ test('Dailymotion + Streamable', () => {
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
assert.equal(getStreamableId('https://streamable.com/login'), null); // reserved page
});
test('Twitch: channel / video / clip', () => {
@@ -132,6 +137,10 @@ test('Twitch: channel / video / clip', () => {
type: 'clip',
value: 'CoolSlug',
});
// reserved utility pages are not channels
assert.equal(getTwitchTarget('https://twitch.tv/directory'), null);
assert.equal(getTwitchTarget('https://twitch.tv/settings'), null);
assert.equal(getTwitchTarget('https://twitch.tv/videos'), null); // bare /videos, not a channel
});
test('Spotify target + height', () => {
@@ -153,6 +162,9 @@ test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
assert.equal(isSoundCloudTrack('https://soundcloud.com/discover/xyz'), false); // site section
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets'), false); // profile-tab listing
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets/my-set'), true); // a real set
});
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
@@ -287,6 +299,8 @@ test('Bluesky / Loom / Kick', () => {
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
assert.equal(getKickChannel('https://kick.com/browse'), null); // nav page, not a channel
assert.equal(getKickChannel('https://kick.com/following'), null);
assert.ok(
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
'player.kick.com/streamer?autoplay=true',
+106 -6
View File
@@ -66,8 +66,10 @@ 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;
// Canonical /{id} or unlisted /{id}/{hash}
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
// Canonical /{id} or unlisted /{id}/{hash}. The hash is a lowercase-hex token
// (constrain it so a normal video's trailing segment — /likes, /settings, a
// review slug — isn't captured as a bogus `h=` param that Vimeo then rejects).
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-f]{6,}))?/);
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+)/);
@@ -192,12 +194,26 @@ export function getDailymotionId(url: string): string | null {
// --- Streamable -----------------------------------------------------------
// Streamable's own utility/first-path pages that are not video ids.
const STREAMABLE_RESERVED = new Set([
'e',
'login',
'signup',
'settings',
'account',
'dashboard',
'help',
'terms',
'privacy',
'about',
]);
export function getStreamableId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
return m && m[1] !== 'e' ? m[1] : null;
return m && !STREAMABLE_RESERVED.has(m[1].toLowerCase()) ? m[1] : null;
} catch {
return null;
}
@@ -210,6 +226,32 @@ export type TwitchTarget =
| { type: 'video'; value: string }
| { type: 'clip'; value: string };
// Twitch's own reserved first-path segments — single-segment paths that are
// utility pages, not channels, and must NOT be embedded as `channel=<x>`.
const TWITCH_RESERVED = new Set([
'directory',
'videos',
'settings',
'subscriptions',
'following',
'followers',
'friends',
'inventory',
'wallet',
'drops',
'prime',
'turbo',
'downloads',
'jobs',
'store',
'search',
'dashboard',
'popout',
'p',
'u',
'team',
]);
export function getTwitchTarget(url: string): TwitchTarget | null {
try {
const { hostname, pathname } = new URL(url);
@@ -219,7 +261,9 @@ export function getTwitchTarget(url: string): TwitchTarget | null {
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] };
if (parts.length === 1 && parts[0] && !TWITCH_RESERVED.has(parts[0].toLowerCase())) {
return { type: 'channel', value: parts[0] };
}
}
} catch {
/* ignore */
@@ -248,6 +292,38 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
// --- SoundCloud -----------------------------------------------------------
// SoundCloud's own site sections (first segment) that are never `<artist>`.
const SOUNDCLOUD_RESERVED = new Set([
'discover',
'you',
'stream',
'search',
'upload',
'settings',
'notifications',
'messages',
'tags',
'charts',
'people',
'pages',
'terms',
'pro',
]);
// Profile tabs — `/<artist>/<tab>` is a listing, not a single track (a real set
// is the deeper `/<artist>/sets/<slug>`, which has length >= 3 and is allowed).
const SOUNDCLOUD_PROFILE_TABS = new Set([
'tracks',
'sets',
'albums',
'reposts',
'likes',
'following',
'followers',
'comments',
'popular-tracks',
'toptracks',
]);
export function isSoundCloudTrack(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
@@ -260,7 +336,11 @@ export function isSoundCloudTrack(url: string): boolean {
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length >= 2;
if (parts.length < 2) return false;
if (SOUNDCLOUD_RESERVED.has(parts[0].toLowerCase())) return false;
// `/<artist>/<tab>` profile-tab listing (not a playable single track/set).
if (parts.length === 2 && SOUNDCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return false;
return true;
} catch {
return false;
}
@@ -391,6 +471,22 @@ export function getLoomId(url: string): string | null {
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
// Kick's own reserved first-path segments (nav pages, not channels).
const KICK_RESERVED = new Set([
'browse',
'following',
'category',
'categories',
'search',
'messages',
'subscriptions',
'settings',
'wallet',
'help',
'clips',
'dashboard',
]);
export function getKickChannel(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
@@ -399,7 +495,11 @@ export function getKickChannel(url: string): string | null {
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
return parts.length === 1 &&
/^[A-Za-z0-9_]+$/.test(parts[0]) &&
!KICK_RESERVED.has(parts[0].toLowerCase())
? parts[0]
: null;
} catch {
return null;
}