From fbaa921f833898017321efcdb3b05521683cc689 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 6 Jul 2026 20:55:47 -0400 Subject: [PATCH] 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 --- .../components/url-preview/UrlPreview.css.tsx | 25 +++++- .../components/url-preview/UrlPreviewCard.tsx | 80 +++++++++++++------ src/app/utils/videoEmbed.test.ts | 36 +++++++++ src/app/utils/videoEmbed.ts | 71 +++++++++++++++- 4 files changed, 185 insertions(+), 27 deletions(-) diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 5c2bea788..3e29e20f8 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -14,6 +14,13 @@ export const UrlPreview = style([ }, ]); +// Wider, responsive card for interactive embeds (video players, tweets, posts) +// so the player chrome / tweet content isn't cramped or clipped. Defined after +// UrlPreview so its width wins the cascade. +export const UrlPreviewWide = style({ + width: 'min(34rem, 92vw)', +}); + export const UrlPreviewImg = style([ DefaultReset, { @@ -209,6 +216,9 @@ export const EmbedMediaLandscape = style([ aspectRatio: '16 / 9', overflow: 'hidden', backgroundColor: color.Surface.Container, + selectors: { + '&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto' }, + }, }, ]); @@ -216,12 +226,15 @@ export const EmbedMediaPortrait = style([ DefaultReset, { position: 'relative', - width: toRem(220), + width: toRem(300), maxWidth: '100%', aspectRatio: '9 / 16', margin: '0 auto', overflow: 'hidden', backgroundColor: color.Surface.Container, + selectors: { + '&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto', margin: 0 }, + }, }, ]); @@ -430,6 +443,16 @@ export const BadgeAppleMusic = style({ color: '#ffffff', }); +export const BadgeInstagram = style({ + backgroundColor: '#e1306c', + color: '#ffffff', +}); + +export const BadgeTidal = style({ + backgroundColor: '#000000', + color: '#ffffff', +}); + // --------------------------------------------------------------------------- // Twitch LIVE badge // --------------------------------------------------------------------------- diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index e122f9c09..bee4311e6 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -991,8 +991,18 @@ const EMBED_BADGE: Record = { 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(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 ? ( {title} @@ -1057,16 +1072,21 @@ function MediaEmbedCard({ ); + const enterFullscreen = () => { + mediaRef.current?.requestFullscreen?.().catch(() => undefined); + }; + return ( -
+
{playing ? ( + // No sandbox: these are CSP-allowlisted trusted hosts, and sandboxing + // breaks player features (fullscreen, TikTok's flow, etc.).