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:
+106
-6
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user