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:
@@ -88,8 +88,9 @@ export function RenderMessageContent({
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||
// fetches + iframes at once.
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
|
||||
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||
// twice would otherwise render sibling cards with identical React keys.
|
||||
const filteredUrls = [...new Set(urls.filter((url) => !testMatrixTo(url)))].slice(0, 6);
|
||||
if (filteredUrls.length === 0) return undefined;
|
||||
return (
|
||||
<UrlPreviewHolder>
|
||||
|
||||
@@ -116,11 +116,15 @@ function isGitHubRepo(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep these hosts + the /status(es) pattern in sync with getTweetId
|
||||
// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has
|
||||
// an extractable id but never routes to the Twitter card or "View post" embed.
|
||||
const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']);
|
||||
|
||||
function isTwitter(url: string): boolean {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
return h === 'twitter.com' || h === 'x.com';
|
||||
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -129,9 +133,8 @@ function isTwitter(url: string): boolean {
|
||||
function isTwitterTweet(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
if (h !== 'twitter.com' && h !== 'x.com') return false;
|
||||
return /\/status\/\d+/.test(pathname);
|
||||
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
|
||||
return /\/status(?:es)?\/\d+/.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -1499,9 +1502,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
}
|
||||
|
||||
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
// Route through the homeserver like every other card — a raw og:image would
|
||||
// be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
|
||||
// defeats the click-to-play facade on a nonstandard one.
|
||||
const artworkUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||
: null;
|
||||
const spotifyType = getSpotifyType(url) ?? 'track';
|
||||
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
||||
|
||||
@@ -1552,9 +1563,14 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
||||
}
|
||||
|
||||
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const thumbnailUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1671,9 +1687,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
|
||||
|
||||
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const iconUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1834,9 +1855,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
|
||||
}
|
||||
|
||||
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const posterUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2075,28 +2101,34 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
||||
// player chrome / tweet content isn't cramped or clipped.
|
||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||
const wide = !!embed || isTwitterTweet(url);
|
||||
// Short "copy-link" links carry no id, so the embed is only resolvable from
|
||||
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
|
||||
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
|
||||
// the wide layout, not the cramped narrow one.
|
||||
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||
if (embed) return embed;
|
||||
const ogUrl = prev['og:url'];
|
||||
return typeof ogUrl === 'string' && ogUrl !== url
|
||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||
: null;
|
||||
};
|
||||
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
|
||||
// in the card flex row; stack them on phones (no-op for the single-column
|
||||
// embed cards). Desktop keeps the row layout.
|
||||
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
||||
const cardClass =
|
||||
const buildCardClass = (wide: boolean): string | undefined =>
|
||||
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||
const renderContent = (
|
||||
prev: IPreviewUrlResponse,
|
||||
resolvedEmbed: MediaEmbed | null,
|
||||
): React.ReactNode => {
|
||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
||||
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
|
||||
// redirects) don't carry the id, so fall back to the canonical og:url that
|
||||
// the homeserver already resolved when fetching the preview.
|
||||
const ogUrl = prev['og:url'];
|
||||
const resolvedEmbed =
|
||||
embed ??
|
||||
(typeof ogUrl === 'string' && ogUrl !== url
|
||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||
: null);
|
||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
|
||||
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
|
||||
// folds in the og:url fallback for short "copy-link" share URLs.
|
||||
if (resolvedEmbed) {
|
||||
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
|
||||
}
|
||||
@@ -2189,17 +2221,23 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
|
||||
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
||||
if (previewStatus.status === AsyncStatus.Success) {
|
||||
const content = renderContent(previewStatus.data);
|
||||
const prev = previewStatus.data;
|
||||
const resolvedEmbed = resolveEmbed(prev);
|
||||
const content = renderContent(prev, resolvedEmbed);
|
||||
if (content === null) return null;
|
||||
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
|
||||
// link that resolves to a player still gets the wide layout.
|
||||
const wide = !!resolvedEmbed || isTwitterTweet(url);
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
||||
{content}
|
||||
</UrlPreview>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
<UrlPreview {...props} ref={ref} className={buildCardClass(!!embed || isTwitterTweet(url))}>
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user