feat(embeds): fullscreen, wider cards, Instagram/Tidal/Reddit; fix TikTok/X/Twitch
Feedback fixes + new platforms: - Fullscreen: add a universal 'Fullscreen' button on video embeds (requests fullscreen on the media container) so Shorts/TikTok/etc. can go fullscreen regardless of each player's own controls. Portrait media enlarged (220->300). - Drop the iframe sandbox (CSP-allowlisted trusted hosts; sandbox was breaking player features and likely TikTok). - Wider, responsive embed/tweet cards (min(34rem,92vw)) so Twitch player chrome isn't cramped and X posts stop getting clipped. - Instagram (p/reel/tv), Tidal (track/album/playlist/video), and Reddit posts now embed. Reddit uses redditmedia.com to bypass the homeserver's blocked preview (Reddit serves it a bot-check 'please wait for verification' page); a bot-wall title filter keeps that garbage out of any caption. New pure parsers unit-tested (videoEmbed.test.ts, 17 cases). Desktop + live web CSP frame-src updated for instagram.com, embed.tidal.com, redditmedia.com. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -991,8 +991,18 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
||||
spotify: { label: 'Spotify', class: previewCss.BadgeSpotify },
|
||||
soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud },
|
||||
applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic },
|
||||
tidal: { label: 'TIDAL', class: previewCss.BadgeTidal },
|
||||
instagram: { label: 'Instagram', class: previewCss.BadgeInstagram },
|
||||
reddit: { label: 'Reddit', class: previewCss.BadgeReddit },
|
||||
};
|
||||
|
||||
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
||||
// "please wait for verification" page; don't surface that as the caption.
|
||||
const looksLikeBotWall = (s: string): boolean =>
|
||||
/please wait|are you a (human|robot)|verifying|verification|just a moment|attention required/i.test(
|
||||
s,
|
||||
);
|
||||
|
||||
// One media-forward tile for every embeddable provider (video / portrait / audio).
|
||||
// Privacy-friendly facade: shows the homeserver's cached thumbnail + a play
|
||||
// button; only on click does it swap in the provider iframe, so nothing loads
|
||||
@@ -1011,12 +1021,17 @@ function MediaEmbedCard({
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const mediaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const rawTitle = prev['og:title'] ?? '';
|
||||
const rawDescription = prev['og:description'] ?? '';
|
||||
const portrait = embed.kind === 'portrait';
|
||||
const audio = embed.kind === 'audio';
|
||||
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
|
||||
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
|
||||
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
|
||||
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
|
||||
const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const thumbnailUrl = mxcImage
|
||||
? mxcUrlToHttp(
|
||||
mx,
|
||||
@@ -1035,12 +1050,12 @@ function MediaEmbedCard({
|
||||
? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts }
|
||||
: (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' });
|
||||
|
||||
const mediaClass = audio
|
||||
const mediaClass = fixedHeight
|
||||
? previewCss.EmbedMediaAudio
|
||||
: portrait
|
||||
? previewCss.EmbedMediaPortrait
|
||||
: previewCss.EmbedMediaLandscape;
|
||||
const mediaStyle = audio ? { height: `${embed.height ?? 152}px` } : undefined;
|
||||
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
|
||||
|
||||
const thumb = thumbnailUrl ? (
|
||||
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
|
||||
@@ -1057,16 +1072,21 @@ function MediaEmbedCard({
|
||||
</span>
|
||||
);
|
||||
|
||||
const enterFullscreen = () => {
|
||||
mediaRef.current?.requestFullscreen?.().catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column">
|
||||
<div className={mediaClass} style={mediaStyle}>
|
||||
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
|
||||
{playing ? (
|
||||
// No sandbox: these are CSP-allowlisted trusted hosts, and sandboxing
|
||||
// breaks player features (fullscreen, TikTok's flow, etc.).
|
||||
<iframe
|
||||
className={previewCss.EmbedIframe}
|
||||
src={embed.embedUrl}
|
||||
title={title || badge.label}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -1094,18 +1114,25 @@ function MediaEmbedCard({
|
||||
)}
|
||||
</div>
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
<SiteBadge label={badge.label} colorClass={badge.class} />
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
<SiteBadge label={badge.label} colorClass={badge.class} />
|
||||
</Text>
|
||||
{playing && video && (
|
||||
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
|
||||
<Text size="T200">⛶ Fullscreen</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
{title && (
|
||||
<Text truncate priority="400">
|
||||
<b>{title}</b>
|
||||
@@ -1726,10 +1753,15 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
// 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);
|
||||
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||
// Spotify/SoundCloud) gets the media-forward click-to-play tile.
|
||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
||||
if (embed) {
|
||||
return <MediaEmbedCard url={url} prev={prev} embed={embed} />;
|
||||
}
|
||||
@@ -1820,14 +1852,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
const content = renderContent(previewStatus.data);
|
||||
if (content === null) return null;
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref}>
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
{content}
|
||||
</UrlPreview>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref}>
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user