Compare commits

...

8 Commits

Author SHA1 Message Date
jared 013f113bc2 feat(embeds): add Bluesky, Loom, Kick
CI / Build & Quality Checks (push) Successful in 10m47s
CI / Trigger Desktop Build (push) Successful in 9s
From the coverage-review agent's ranked recommendations (clean iframes, ids in
the URL, one CSP host each):
- Bluesky: bsky.app/profile/{authority}/post/{rkey} → embed.bsky.app (rich, self-
  resizing like the other post embeds).
- Loom: loom.com/share|embed/{id} → www.loom.com/embed/{id} (16:9).
- Kick: kick.com/{channel} → player.kick.com/{channel} (live channels only; VODs/
  clips have no clean embed and fall back to a link).

Parsers unit-tested. CSP frame-src gains embed.bsky.app / www.loom.com /
player.kick.com (desktop + live web, both updated). Needs live verification once
deployed since the embeds themselves can't be exercised from here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:50:39 -04:00
jared a52c9e12a4 fix(embeds): quality pass — close button, focus, a11y, perf, SoundCloud revert
From the quality-review agents:
- Revert on.soundcloud.com support: the w.soundcloud widget doesn't follow the
  redirect (needs an oEmbed resolve, deferred).
- Add a Close button to playing video/TikTok embeds and a Collapse button to the
  expanded X post — playback was previously one-way (only escapable by scrolling).
- focus-visible ring on the embed facade (folds resets outline:none, leaving
  keyboard users with no indicator).
- Only subscribe to resize postMessages while the iframe is mounted (was attaching
  a global listener per Instagram/Reddit facade before play).
- TikTok oEmbed fetch now uses AbortController (abort on unmount) + aria-busy /
  'Loading…' label on the resolving spinner.
- Decorative facade thumbnails use alt="" (parent already names them); drop the
  dangling-colon aria-labels when there's no title.
- Cap URL previews at 6 per message so a link-dump can't spawn dozens of fetches.

Tests 728. No CSP change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:45:51 -04:00
jared a28c305835 feat(embeds): TikTok URL trim, SoundCloud on. + redd.it short links
- TikTok player URL trimmed to ?autoplay=1&rel=0 (the control params were all
  default-on no-ops).
- SoundCloud on.soundcloud.com share short links now embed (widget follows the
  redirect via w.soundcloud.com).
- redd.it short links now embed via embed.reddit.com/comments/<id>/ (the redirect
  target is reddit.com/comments/<id>, no subreddit needed).

No CSP change (hosts already allowlisted). Vimeo event/ondemand deferred — the
embed format/host couldn't be verified from a fake id and would need a CSP
change; leaving it for the review agents to research. Tests 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:32:09 -04:00
jared 3694a3612d fix(embeds): second-pass review — Reddit auto-height, sandbox, Vimeo/Shorts gaps
From the second review pass (agents inspected providers' live embed scripts):
- FIX Reddit auto-height (was broken): embed.reddit.com posts
  { type:'resize.embed', data:<height> } — height is under 'data', not 'height',
  so it never resized and clipped taller posts (scrolling=no). Add that shape.
- Align TweetEmbed sandbox with EMBED_SANDBOX (adds allow-popups-to-escape-sandbox)
  so links/login popups opened from inside a tweet aren't crippled.
- Vimeo: resolve channel/group/album video forms (vimeo.com/channels/{n}/{id} etc.),
  not just paths starting with the id.
- Mobile Shorts: m.youtube.com/shorts/{id} now renders portrait 9:16, not landscape.
- Move extractEmbedHeight into videoEmbed.ts and unit-test all three resize shapes
  (Instagram MEASURE / Reddit resize.embed / Twitter twttr.private.resize).

Agents confirmed everything else current & robust (Dailymotion geo host, Tidal
gridify, sandbox tokens are a safe superset, X still on platform.twitter.com,
550px cap). Tests 21 in this suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:08:51 -04:00
jared 4de2d233ef feat(embeds): apply review findings + fix TikTok portrait padding
From the review-agent audit:
- Dailymotion: move off the Sept-2024-deprecated /embed/video path to
  geo.dailymotion.com/player.html.
- Reddit: point at embed.reddit.com (www.redditmedia.com now 301s there).
- Vimeo: parse the unlisted hash (vimeo.com/{id}/{hash}) and pass &h=…, add dnt=1.
- Tidal: layout=gridify + ~275px height for albums/playlists (fixes narrow player).
- YouTube/Shorts: playsinline=1 (iOS keeps playback inline); parse /live/ +
  music.youtube.com.
- Apple Music: /music-video/ renders 16:9 instead of a fixed audio height.
- Re-add a minimal sandbox to all media iframes (omits allow-top-navigation →
  blocks phishing redirects) — defense-in-depth atop the CSP frame-src allowlist.
- Self-resize Instagram + Reddit post embeds via a shared useIframeAutoHeight hook
  (also now covers the Tweet embed; matches platform.x.com origin too); drop the
  fixed 720/480 heights. Cap tweet/post columns at ~550px, centered.

Also from user feedback: TikTok portrait player dropped music_info/description,
which forced TikTok's wide 'video + info panel' layout and left empty space
beside the video — now a clean 9:16 player that fills the box.

Tests 726 pass. CSP frame-src gains embed.reddit.com (separate desktop commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:56:47 -04:00
jared b247a0447c fix(embeds): resolve TikTok short links via oEmbed + og:url fallback
CI / Build & Quality Checks (push) Successful in 10m51s
CI / Trigger Desktop Build (push) Successful in 9s
TikTok 'copy-link' share URLs (vm.tiktok.com, tiktok.com/t/…) carry no video id
and the homeserver's link preview is bot-walled (generic 'TikTok - Make Your
Day', no og:url/og:image), so they fell through to the static fallback card with
no play button.

New TikTokEmbedCard resolves the id client-side via TikTok's CORS-enabled oEmbed
API on click (keeps the facade privacy model), then plays the player/v1 embed
(portrait, autoplay + full controls + our fullscreen button). Canonical
/video/<id> links skip the lookup. Also added a general og:url fallback so other
short/redirect links resolve to their canonical form when the raw URL doesn't.

Web CSP connect-src gains www.tiktok.com for the oEmbed fetch (desktop already
allows https:). Tests 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:03:44 -04:00
jared 6f15f7f56e fix(embeds): wider card for Tidal, declutter caption while video plays
- Widen the interactive-embed card to min(38rem, 94vw) so the Tidal player (and
  other audio/video embeds) has enough width.
- Hide the title/description caption while a video embed is playing, so the text
  below it stops squeezing the player small (Twitch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:23:32 -04:00
jared fbaa921f83 feat(embeds): fullscreen, wider cards, Instagram/Tidal/Reddit; fix TikTok/X/Twitch
CI / Build & Quality Checks (push) Successful in 10m35s
CI / Trigger Desktop Build (push) Successful in 8s
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>
2026-07-06 20:55:47 -04:00
5 changed files with 832 additions and 117 deletions
+3 -1
View File
@@ -86,7 +86,9 @@ export function RenderMessageContent({
eventId, eventId,
}: RenderMessageContentProps) { }: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => { const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url)); // 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);
if (filteredUrls.length === 0) return undefined; if (filteredUrls.length === 0) return undefined;
return ( return (
<UrlPreviewHolder> <UrlPreviewHolder>
@@ -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(38rem, 94vw)',
});
export const UrlPreviewImg = style([ export const UrlPreviewImg = style([
DefaultReset, DefaultReset,
{ {
@@ -209,6 +216,9 @@ export const EmbedMediaLandscape = style([
aspectRatio: '16 / 9', aspectRatio: '16 / 9',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto' },
},
}, },
]); ]);
@@ -216,12 +226,15 @@ export const EmbedMediaPortrait = style([
DefaultReset, DefaultReset,
{ {
position: 'relative', position: 'relative',
width: toRem(220), width: toRem(300),
maxWidth: '100%', maxWidth: '100%',
aspectRatio: '9 / 16', aspectRatio: '9 / 16',
margin: '0 auto', margin: '0 auto',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto', margin: 0 },
},
}, },
]); ]);
@@ -242,6 +255,12 @@ export const EmbedFacade = style([
':hover': { ':hover': {
filter: 'brightness(0.85)', filter: 'brightness(0.85)',
}, },
selectors: {
'&:focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: '-2px',
},
},
}, },
]); ]);
@@ -257,11 +276,14 @@ export const EmbedIframe = style([
}, },
]); ]);
// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline). // Variable-height iframe that sizes itself (X/Twitter, Instagram, Reddit posts —
// height set inline). Capped ~550px (X/IG native max) + centered in the wide card.
export const EmbedIframeStatic = style([ export const EmbedIframeStatic = style([
DefaultReset, DefaultReset,
{ {
width: '100%', width: '100%',
maxWidth: toRem(550),
margin: '0 auto',
border: 0, border: 0,
display: 'block', display: 'block',
}, },
@@ -430,6 +452,31 @@ export const BadgeAppleMusic = style({
color: '#ffffff', color: '#ffffff',
}); });
export const BadgeInstagram = style({
backgroundColor: '#e1306c',
color: '#ffffff',
});
export const BadgeBluesky = style({
backgroundColor: '#0085ff',
color: '#ffffff',
});
export const BadgeLoom = style({
backgroundColor: '#625df5',
color: '#ffffff',
});
export const BadgeKick = style({
backgroundColor: '#53fc18',
color: '#000000',
});
export const BadgeTidal = style({
backgroundColor: '#000000',
color: '#ffffff',
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Twitch LIVE badge // Twitch LIVE badge
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+378 -77
View File
@@ -19,7 +19,17 @@ import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard'; import { onEnterOrSpace } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { getTweetId, MediaEmbed, parseMediaEmbed } from '../../utils/videoEmbed'; import {
extractEmbedHeight,
getTikTokVideoId,
getTweetId,
isTikTokLink,
MediaEmbed,
parseMediaEmbed,
tiktokIdFromOembed,
tiktokOembedUrl,
tiktokPlayerEmbedUrl,
} from '../../utils/videoEmbed';
const linkStyles = { color: color.Success.Main }; const linkStyles = { color: color.Success.Main };
@@ -544,14 +554,52 @@ function TikTokCard({
// Card 3: Twitter / X // Card 3: Twitter / X
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Interactive X/Twitter post embed. The official platform.twitter.com iframe // Origins allowed to drive a self-resizing embed (stable refs → effect deps).
// renders the full post — playable video/GIF, image galleries, quote tweets — const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
// and reports its height back to us via postMessage, which we apply so the card const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
// grows to fit. Scoped to messages from OUR iframe + the platform.twitter.com const REDDIT_ORIGINS = ['https://embed.reddit.com'];
// origin. Only mounted after the user clicks "View post" (privacy facade). const BLUESKY_ORIGINS = ['https://embed.bsky.app'];
const NO_RESIZE_ORIGINS: string[] = [];
const EMBED_MAX_HEIGHT = 1400;
// Defense-in-depth on top of the CSP frame-src allowlist. Critically OMITS
// allow-top-navigation, so a compromised embed can't redirect the whole app
// (phishing). Fullscreen still works — it's gated by allow=, not a sandbox token.
const EMBED_SANDBOX =
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation';
// Variable-height iframe embeds (Twitter/Instagram/Reddit) report their content
// height via postMessage; listen — scoped to the allowed origins + OUR iframe —
// and grow to fit. `origins` must be a stable reference.
function useIframeAutoHeight(origins: string[], initial: number) {
const ref = useRef<HTMLIFrameElement | null>(null);
const [height, setHeight] = useState(initial);
useEffect(() => {
if (origins.length === 0) return undefined;
const onMessage = (e: MessageEvent) => {
if (!origins.includes(e.origin)) return;
if (ref.current && e.source !== ref.current.contentWindow) return;
let data: unknown = e.data;
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch {
return;
}
}
const h = extractEmbedHeight(data);
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), EMBED_MAX_HEIGHT));
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [origins]);
return { ref, height };
}
// Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets.
// Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade).
function TweetEmbed({ id }: { id: string }) { function TweetEmbed({ id }: { id: string }) {
const iframeRef = useRef<HTMLIFrameElement | null>(null); const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
const [height, setHeight] = useState(320);
const theme = const theme =
typeof window !== 'undefined' && typeof window !== 'undefined' &&
window.matchMedia && window.matchMedia &&
@@ -559,39 +607,16 @@ function TweetEmbed({ id }: { id: string }) {
? 'light' ? 'light'
: 'dark'; : 'dark';
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.origin !== 'https://platform.twitter.com') return;
if (iframeRef.current && e.source !== iframeRef.current.contentWindow) return;
let payload: unknown = e.data;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
return;
}
}
const embed = (payload as { 'twttr.embed'?: unknown } | null)?.['twttr.embed'];
const calls = Array.isArray(embed) ? embed : embed ? [embed] : [];
calls.forEach((c: { method?: string; params?: Array<{ height?: number }> }) => {
const h = c?.method === 'twttr.private.resize' ? c.params?.[0]?.height : undefined;
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), 1400));
});
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
return ( return (
<iframe <iframe
ref={iframeRef} ref={ref}
className={previewCss.EmbedIframeStatic} className={previewCss.EmbedIframeStatic}
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent( src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
id, id,
)}&theme=${theme}&dnt=true`} )}&theme=${theme}&dnt=true`}
title="Post on X" title="Post on X"
style={{ height: `${height}px` }} style={{ height: `${height}px` }}
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" sandbox={EMBED_SANDBOX}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy" loading="lazy"
scrolling="no" scrolling="no"
@@ -652,7 +677,27 @@ function TwitterCard({
if (canEmbed && expanded && tweetId) { if (canEmbed && expanded && tweetId) {
return ( return (
<> <>
{header} <div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate>
{name}
</Text>
{handle && (
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
@{handle}
</Text>
)}
<Box grow="Yes" />
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</div>
<UrlPreviewContent> <UrlPreviewContent>
<TweetEmbed id={tweetId} /> <TweetEmbed id={tweetId} />
</UrlPreviewContent> </UrlPreviewContent>
@@ -991,8 +1036,21 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
spotify: { label: 'Spotify', class: previewCss.BadgeSpotify }, spotify: { label: 'Spotify', class: previewCss.BadgeSpotify },
soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud }, soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud },
applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic }, 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 },
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
loom: { label: 'Loom', class: previewCss.BadgeLoom },
kick: { label: 'Kick', class: previewCss.BadgeKick },
}; };
// 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). // One media-forward tile for every embeddable provider (video / portrait / audio).
// Privacy-friendly facade: shows the homeserver's cached thumbnail + a play // 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 // button; only on click does it swap in the provider iframe, so nothing loads
@@ -1011,12 +1069,31 @@ function MediaEmbedCard({
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds'); const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const title = prev['og:title'] ?? ''; const rawTitle = prev['og:title'] ?? '';
const description = prev['og:description'] ?? ''; const rawDescription = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const portrait = embed.kind === 'portrait'; const portrait = embed.kind === 'portrait';
const audio = embed.kind === 'audio'; const video = embed.kind === 'landscape' || embed.kind === 'portrait';
const rich = embed.kind === 'rich';
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
// Instagram/Reddit report their height via postMessage → self-resize.
const resizeOrigins = !rich
? NO_RESIZE_ORIGINS
: embed.provider === 'instagram'
? INSTAGRAM_ORIGINS
: embed.provider === 'bluesky'
? BLUESKY_ORIGINS
: REDDIT_ORIGINS;
// Only subscribe to resize messages while the iframe is actually mounted.
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
playing ? resizeOrigins : NO_RESIZE_ORIGINS,
embed.height ?? 480,
);
// '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 const thumbnailUrl = mxcImage
? mxcUrlToHttp( ? mxcUrlToHttp(
mx, mx,
@@ -1035,15 +1112,16 @@ function MediaEmbedCard({
? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts } ? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts }
: (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' }); : (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' });
const mediaClass = audio const mediaClass = fixedHeight
? previewCss.EmbedMediaAudio ? previewCss.EmbedMediaAudio
: portrait : portrait
? previewCss.EmbedMediaPortrait ? previewCss.EmbedMediaPortrait
: previewCss.EmbedMediaLandscape; : previewCss.EmbedMediaLandscape;
const mediaStyle = audio ? { height: `${embed.height ?? 152}px` } : undefined; const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
const thumb = thumbnailUrl ? ( const thumb = thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" /> // alt="" — the parent button/link already carries the accessible name.
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : ( ) : (
<span className={previewCss.EmbedPlaceholder}> <span className={previewCss.EmbedPlaceholder}>
<Icon size="600" src={Icons.Play} /> <Icon size="600" src={Icons.Play} />
@@ -1057,16 +1135,195 @@ function MediaEmbedCard({
</span> </span>
); );
const enterFullscreen = () => {
mediaRef.current?.requestFullscreen?.().catch(() => undefined);
};
return ( return (
<Box direction="Column"> <Box direction="Column">
<div className={mediaClass} style={mediaStyle}> {playing && rich ? (
{playing ? ( // Rich post embeds (Instagram/Reddit) are variable-height and self-size.
<iframe
ref={resizeRef}
className={previewCss.EmbedIframeStatic}
src={embed.embedUrl}
title={title || badge.label}
style={{ height: `${resizeHeight}px` }}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
scrolling="no"
/>
) : (
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
{playing ? (
<iframe
className={previewCss.EmbedIframe}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
/>
) : inlineMediaEmbeds ? (
<button
type="button"
className={previewCss.EmbedFacade}
onClick={() => setPlaying(true)}
aria-label={`Play: ${title || badge.label}`}
>
{thumb}
{playOverlay}
</button>
) : (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
>
{thumb}
{playOverlay}
</a>
)}
</div>
)}
<UrlPreviewContent>
<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 && (
<Box alignItems="Center" gap="100" shrink="No">
{video && (
<Chip
variant="Secondary"
radii="Pill"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
)}
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)}
</Box>
{/* While a video plays, drop the title/description so the player isn't
squeezed by text below it. */}
{!(playing && video) && title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{!(playing && video) && description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</Box>
);
}
// TikTok needs its own card: short "copy-link" URLs (vm.tiktok.com, tiktok.com/t/…)
// don't carry the video id, and the homeserver's link preview is bot-walled. So we
// resolve the id client-side via TikTok's CORS-enabled oEmbed API (on click, to
// keep the facade privacy model), then play the player/v1 embed.
function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [videoId, setVideoId] = useState<string | null>(() => getTikTokVideoId(url));
const [playing, setPlaying] = useState(false);
const [resolving, setResolving] = useState(false);
const [failed, setFailed] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
useEffect(() => () => abortRef.current?.abort(), []);
const mxcImage = prev['og:image'] as string | undefined;
const rawTitle = prev['og:title'] ?? '';
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 320, 568, 'scale', false)
: undefined;
const start = useCallback(async () => {
if (videoId) {
setPlaying(true);
return;
}
setResolving(true);
setFailed(false);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal });
const id = tiktokIdFromOembed(await res.json());
if (controller.signal.aborted) return;
if (id) {
setVideoId(id);
setPlaying(true);
} else {
setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
} finally {
if (!controller.signal.aborted) setResolving(false);
}
}, [url, videoId]);
const enterFullscreen = () => mediaRef.current?.requestFullscreen?.().catch(() => undefined);
const facadeInner = (
<>
{thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : (
<span className={previewCss.EmbedPlaceholder} />
)}
<span className={previewCss.MediaPlayOverlay}>
<span className={previewCss.MediaPlayButton}>
{resolving ? <Spinner size="100" /> : <Icon size="400" src={Icons.Play} />}
</span>
</span>
</>
);
return (
<Box direction="Column">
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
{playing && videoId ? (
<iframe <iframe
className={previewCss.EmbedIframe} className={previewCss.EmbedIframe}
src={embed.embedUrl} src={tiktokPlayerEmbedUrl(videoId)}
title={title || badge.label} title={title || 'TikTok'}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write" allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" sandbox={EMBED_SANDBOX}
allowFullScreen allowFullScreen
loading="lazy" loading="lazy"
/> />
@@ -1074,11 +1331,14 @@ function MediaEmbedCard({
<button <button
type="button" type="button"
className={previewCss.EmbedFacade} className={previewCss.EmbedFacade}
onClick={() => setPlaying(true)} onClick={start}
aria-label={`Play: ${title || badge.label}`} disabled={resolving}
aria-busy={resolving}
aria-label={
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
}
> >
{thumb} {facadeInner}
{playOverlay}
</button> </button>
) : ( ) : (
<a <a
@@ -1086,34 +1346,56 @@ function MediaEmbedCard({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={previewCss.EmbedFacade} className={previewCss.EmbedFacade}
aria-label={`Open on ${badge.label}: ${title}`} aria-label={`Open on TikTok${title ? `: ${title}` : ''}`}
> >
{thumb} {facadeInner}
{playOverlay}
</a> </a>
)} )}
</div> </div>
<UrlPreviewContent> <UrlPreviewContent>
<Text <Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
style={linkStyles} <Text
truncate style={linkStyles}
as="a" truncate
href={url} as="a"
target="_blank" href={url}
rel="noreferrer" target="_blank"
size="T200" rel="noreferrer"
priority="300" size="T200"
> priority="300"
<SiteBadge label={badge.label} colorClass={badge.class} /> >
</Text> <SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
{title && ( </Text>
<Text truncate priority="400"> {playing && videoId && (
<b>{title}</b> <Box alignItems="Center" gap="100" shrink="No">
<Chip
variant="Secondary"
radii="Pill"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)}
</Box>
{failed && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Couldnt load this TikTok open it on TikTok.
</Text> </Text>
)} )}
{description && ( {!(playing && videoId) && title && (
<Text size="T200" priority="300"> <Text truncate priority="400">
<UrlPreviewDescription>{description}</UrlPreviewDescription> <b>{title}</b>
</Text> </Text>
)} )}
</UrlPreviewContent> </UrlPreviewContent>
@@ -1726,12 +2008,31 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null; 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 => { const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/ // Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud) gets the media-forward click-to-play tile. // Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
const embed = parseMediaEmbed(url, window.location.hostname); // Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
if (embed) { // redirects) don't carry the id, so fall back to the canonical og:url that
return <MediaEmbedCard url={url} prev={prev} embed={embed} />; // 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);
if (resolvedEmbed) {
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
}
// TikTok video links (incl. short share links) get their own oEmbed-resolving card.
if (isTikTokLink(url)) {
return <TikTokEmbedCard url={url} prev={prev} />;
} }
const variant = getCardVariant(url); const variant = getCardVariant(url);
@@ -1820,14 +2121,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
const content = renderContent(previewStatus.data); const content = renderContent(previewStatus.data);
if (content === null) return null; if (content === null) return null;
return ( return (
<UrlPreview {...props} ref={ref}> <UrlPreview {...props} ref={ref} className={cardClass}>
{content} {content}
</UrlPreview> </UrlPreview>
); );
} }
return ( return (
<UrlPreview {...props} ref={ref}> <UrlPreview {...props} ref={ref} className={cardClass}>
<Box grow="Yes" alignItems="Center" justifyContent="Center"> <Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" /> <Spinner variant="Secondary" size="400" />
</Box> </Box>
+128 -7
View File
@@ -5,7 +5,12 @@ import {
getYoutubeShortsId, getYoutubeShortsId,
isYouTubeShorts, isYouTubeShorts,
getVimeoVideoId, getVimeoVideoId,
getVimeoParts,
extractEmbedHeight,
getTikTokVideoId, getTikTokVideoId,
isTikTokLink,
tiktokIdFromOembed,
tiktokPlayerEmbedUrl,
getDailymotionId, getDailymotionId,
getStreamableId, getStreamableId,
getTwitchTarget, getTwitchTarget,
@@ -13,6 +18,12 @@ import {
isSoundCloudTrack, isSoundCloudTrack,
getAppleMusicEmbed, getAppleMusicEmbed,
getTweetId, getTweetId,
getTidalEmbed,
getInstagramEmbed,
getRedditPostEmbed,
getBlueskyEmbed,
getLoomId,
getKickChannel,
buildVideoEmbedUrl, buildVideoEmbedUrl,
spotifyEmbedHeight, spotifyEmbedHeight,
parseMediaEmbed, parseMediaEmbed,
@@ -25,22 +36,65 @@ test('YouTube: watch / youtu.be / embed / shorts', () => {
assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ?t=42'), 'dQw4w9WgXcQ'); assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ?t=42'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); assert.equal(getYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-'); assert.equal(getYouTubeVideoId('https://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-');
assert.equal(getYouTubeVideoId('https://www.youtube.com/live/abcLIVE123'), 'abcLIVE123');
assert.equal(getYouTubeVideoId('https://music.youtube.com/watch?v=musicId12'), 'musicId12');
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null); assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true); assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123'); assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
}); });
test('Vimeo', () => { test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789'); assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null); assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
assert.deepEqual(getVimeoParts('https://vimeo.com/123456789/abc123'), {
id: '123456789',
hash: 'abc123',
});
assert.ok(parseMediaEmbed('https://vimeo.com/123456789/abc123', 'h')?.embedUrl.includes('h=abc123'));
// channel / group / album share a trailing numeric video id
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');
});
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
assert.equal(extractEmbedHeight({ type: 'MEASURE', details: { height: 640 } }), 640);
assert.equal(extractEmbedHeight({ type: 'resize.embed', data: 812 }), 812); // Reddit
assert.equal(
extractEmbedHeight({ 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }] }),
500,
);
assert.equal(extractEmbedHeight({ height: 300 }), 300); // generic fallback
assert.equal(extractEmbedHeight({ type: 'other' }), undefined);
assert.equal(extractEmbedHeight('not-an-object'), undefined);
});
test('mobile Shorts (m.youtube.com) → portrait', () => {
assert.equal(parseMediaEmbed('https://m.youtube.com/shorts/abc123', 'h')?.kind, 'portrait');
}); });
test('TikTok: canonical /video/<id> only', () => { test('TikTok: canonical /video/<id> only', () => {
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'), '7234567890123456789'); assert.equal(getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'), '7234567890123456789');
assert.equal(getTikTokVideoId('https://vm.tiktok.com/ZMabc/'), null); // short link redirects assert.equal(getTikTokVideoId('https://vm.tiktok.com/ZMabc/'), null); // short link → oEmbed
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null); assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null);
}); });
test('isTikTokLink: canonical + short + vm/vt', () => {
assert.equal(isTikTokLink('https://www.tiktok.com/@user/video/123'), true);
assert.equal(isTikTokLink('https://www.tiktok.com/t/ZTSHuuXWq/'), true);
assert.equal(isTikTokLink('https://vm.tiktok.com/ZMabc/'), true);
assert.equal(isTikTokLink('https://www.tiktok.com/@user'), false); // profile, not a video
assert.equal(isTikTokLink('https://youtube.com/watch?v=x'), false);
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', 'h'), null); // handled by its own card
});
test('tiktokIdFromOembed + player url', () => {
assert.equal(tiktokIdFromOembed({ embed_product_id: '7659555276823006478' }), '7659555276823006478');
assert.equal(tiktokIdFromOembed({ html: '<blockquote data-video-id="123456">' }), '123456');
assert.equal(tiktokIdFromOembed({}), null);
assert.equal(tiktokPlayerEmbedUrl('999'), 'https://www.tiktok.com/player/v1/999?autoplay=1&rel=0');
});
test('Dailymotion + Streamable', () => { test('Dailymotion + Streamable', () => {
assert.equal(getDailymotionId('https://www.dailymotion.com/video/x8abcde'), 'x8abcde'); assert.equal(getDailymotionId('https://www.dailymotion.com/video/x8abcde'), 'x8abcde');
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde'); assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
@@ -84,25 +138,32 @@ test('Spotify target + height', () => {
test('SoundCloud track detection', () => { test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true); assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile 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);
}); });
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => { test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
assert.equal( assert.equal(
buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'), buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'),
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0', 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&playsinline=1',
);
assert.equal(
buildVideoEmbedUrl('vimeo', '42'),
'https://player.vimeo.com/video/42?autoplay=1&dnt=1',
);
assert.equal(
buildVideoEmbedUrl('vimeo', '42', 'h4sh'),
'https://player.vimeo.com/video/42?autoplay=1&dnt=1&h=h4sh',
); );
assert.equal(buildVideoEmbedUrl('vimeo', '42'), 'https://player.vimeo.com/video/42?autoplay=1');
}); });
test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => { test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), { assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
provider: 'youtube', provider: 'youtube',
kind: 'portrait', kind: 'portrait',
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0', embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0&playsinline=1',
}); });
assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape'); assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape');
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', HOST)?.provider, 'tiktok');
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', HOST)?.kind, 'portrait');
assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable'); assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable');
const spotify = parseMediaEmbed('https://open.spotify.com/track/abc', HOST); const spotify = parseMediaEmbed('https://open.spotify.com/track/abc', HOST);
assert.equal(spotify?.kind, 'audio'); assert.equal(spotify?.kind, 'audio');
@@ -136,6 +197,66 @@ test('parseMediaEmbed: Apple Music → audio', () => {
assert.equal(m?.height, 175); assert.equal(m?.height, 175);
}); });
test('Tidal: track (audio) vs video (landscape)', () => {
assert.deepEqual(getTidalEmbed('https://tidal.com/browse/track/12345'), {
kind: 'audio',
embedUrl: 'https://embed.tidal.com/tracks/12345',
height: 120,
});
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.embedUrl, 'https://embed.tidal.com/albums/999?layout=gridify');
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.height, 275);
assert.equal(getTidalEmbed('https://tidal.com/video/555')?.kind, 'landscape');
assert.equal(getTidalEmbed('https://tidal.com/browse'), null);
});
test('Instagram: p / reel / tv → embed path', () => {
assert.equal(getInstagramEmbed('https://www.instagram.com/p/AbC123_-/'), 'https://www.instagram.com/p/AbC123_-/embed/');
assert.equal(getInstagramEmbed('https://instagram.com/reel/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
assert.equal(getInstagramEmbed('https://www.instagram.com/reels/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
assert.equal(getInstagramEmbed('https://www.instagram.com/someuser/'), null);
});
test('Reddit post embed → redditmedia', () => {
assert.equal(
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
'https://embed.reddit.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
);
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
// redd.it short link → comments-only embed (no subreddit in the redirect)
assert.equal(getRedditPostEmbed('https://redd.it/1abc23'), 'https://embed.reddit.com/comments/1abc23/?ref_source=embed&ref=share&embed=true&theme=dark');
});
test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
assert.equal(parseMediaEmbed('https://www.instagram.com/p/abc/', 'h')?.kind, 'rich');
assert.equal(parseMediaEmbed('https://www.reddit.com/r/x/comments/y/z/', 'h')?.provider, 'reddit');
assert.equal(parseMediaEmbed('https://tidal.com/browse/track/1', 'h')?.provider, 'tidal');
});
test('Bluesky / Loom / Kick', () => {
assert.equal(
getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social/post/3kabc'),
'https://embed.bsky.app/embed/alice.bsky.social/app.bsky.feed.post/3kabc',
);
assert.equal(getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social'), null);
assert.equal(parseMediaEmbed('https://bsky.app/profile/a.bsky.social/post/3k', 'h')?.kind, 'rich');
assert.equal(getLoomId('https://www.loom.com/share/abc123DEF'), 'abc123DEF');
assert.equal(getLoomId('https://www.loom.com/embed/abc123DEF'), 'abc123DEF');
assert.equal(
parseMediaEmbed('https://www.loom.com/share/xyz', 'h')?.embedUrl,
'https://www.loom.com/embed/xyz',
);
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
assert.ok(
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
'player.kick.com/streamer?autoplay=true',
),
);
});
test('parseMediaEmbed: Twitch embed carries the parent host', () => { test('parseMediaEmbed: Twitch embed carries the parent host', () => {
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST); const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
assert.equal(chan?.provider, 'twitch'); assert.equal(chan?.provider, 'twitch');
+274 -30
View File
@@ -3,9 +3,10 @@
// //
// "kind" drives how the card lays the embed out: // "kind" drives how the card lays the embed out:
// landscape → 16:9 video portrait → 9:16 video // landscape → 16:9 video portrait → 9:16 video
// audio → short fixed-height player (Spotify/SoundCloud) // audio → short fixed-height player (Spotify/SoundCloud/Tidal/Apple Music)
// rich → tall fixed-height post embed (Instagram / Reddit)
export type EmbedKind = 'landscape' | 'portrait' | 'audio'; export type EmbedKind = 'landscape' | 'portrait' | 'audio' | 'rich';
export type MediaEmbed = { export type MediaEmbed = {
provider: string; provider: string;
@@ -17,16 +18,19 @@ export type MediaEmbed = {
// --- YouTube -------------------------------------------------------------- // --- YouTube --------------------------------------------------------------
const YOUTUBE_HOSTS = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'music.youtube.com'];
export function getYouTubeVideoId(url: string): string | null { export function getYouTubeVideoId(url: string): string | null {
try { try {
const { hostname, pathname, searchParams } = new URL(url); const { hostname, pathname, searchParams } = new URL(url);
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null; if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') { if (YOUTUBE_HOSTS.includes(hostname)) {
if (pathname === '/watch') return searchParams.get('v'); if (pathname === '/watch') return searchParams.get('v');
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/); const m =
if (embedMatch) return embedMatch[1]; pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/) ||
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); pathname.match(/^\/live\/([A-Za-z0-9_-]+)/) ||
if (shortsMatch) return shortsMatch[1]; pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (m) return m[1];
} }
} catch { } catch {
/* ignore */ /* ignore */
@@ -37,7 +41,7 @@ export function getYouTubeVideoId(url: string): string | null {
export function isYouTubeShorts(url: string): boolean { export function isYouTubeShorts(url: string): boolean {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false; if (!YOUTUBE_HOSTS.includes(hostname)) return false;
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname); return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
} catch { } catch {
return false; return false;
@@ -47,7 +51,7 @@ export function isYouTubeShorts(url: string): boolean {
export function getYoutubeShortsId(url: string): string | null { export function getYoutubeShortsId(url: string): string | null {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null; if (!YOUTUBE_HOSTS.includes(hostname)) return null;
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
return m ? m[1] : null; return m ? m[1] : null;
} catch { } catch {
@@ -57,20 +61,30 @@ export function getYoutubeShortsId(url: string): string | null {
// --- Vimeo ---------------------------------------------------------------- // --- Vimeo ----------------------------------------------------------------
export function getVimeoVideoId(url: string): string | null { /** Vimeo id + optional private/unlisted hash (vimeo.com/{id}/{hash}). */
export function getVimeoParts(url: string): { id: string; hash?: string } | null {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null; if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/(\d+)/); // Canonical /{id} or unlisted /{id}/{hash}
return m ? m[1] : null; let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
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+)/);
if (m) return { id: m[1] };
return null;
} catch { } catch {
return null; return null;
} }
} }
export function getVimeoVideoId(url: string): string | null {
return getVimeoParts(url)?.id ?? null;
}
// --- TikTok --------------------------------------------------------------- // --- TikTok ---------------------------------------------------------------
/** Only resolvable for canonical /video/<id> links (short vm.tiktok.com links redirect). */ /** Canonical /video/<id> links only; short links resolve via oEmbed (below). */
export function getTikTokVideoId(url: string): string | null { export function getTikTokVideoId(url: string): string | null {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
@@ -83,6 +97,78 @@ export function getTikTokVideoId(url: string): string | null {
} }
} }
/** Any embeddable TikTok video link — canonical, short (/t/, /v/), or vm/vt.tiktok.com. */
export function isTikTokLink(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h === 'vm.tiktok.com' || h === 'vt.tiktok.com') return true;
if (h === 'tiktok.com') return /^\/(t\/|v\/|embed\/|@[^/]+\/video\/)/.test(pathname);
return false;
} catch {
return false;
}
}
export function tiktokOembedUrl(url: string): string {
return `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
}
/** Extract the numeric video id from a TikTok oEmbed JSON response. */
export function tiktokIdFromOembed(data: {
embed_product_id?: unknown;
html?: unknown;
}): string | null {
const pid = String(data.embed_product_id ?? '').match(/\d+/)?.[0];
if (pid) return pid;
if (typeof data.html === 'string') {
return data.html.match(/data-video-id="(\d+)"/)?.[1] ?? data.html.match(/\/video\/(\d+)/)?.[1] ?? null;
}
return null;
}
/**
* Read a content height out of the postMessage shapes used by the resizable
* embeds. Shapes verified against the providers' live 2026 embed scripts:
* Instagram: { type: 'MEASURE', details: { height } }
* Reddit: { type: 'resize.embed', data: <height> }
* Twitter/X: { 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height }] }] }
*/
export function extractEmbedHeight(data: unknown): number | undefined {
if (!data || typeof data !== 'object') return undefined;
const d = data as {
type?: string;
height?: number;
data?: unknown;
details?: { height?: number };
'twttr.embed'?: unknown;
};
if (d.type === 'MEASURE' && typeof d.details?.height === 'number') return d.details.height;
if (d.type === 'resize.embed' && typeof d.data === 'number') return d.data;
const tw = d['twttr.embed'];
if (tw) {
const calls = (Array.isArray(tw) ? tw : [tw]) as Array<{
method?: string;
params?: Array<{ height?: number }>;
}>;
for (let i = 0; i < calls.length; i += 1) {
const c = calls[i];
if (c?.method === 'twttr.private.resize' && typeof c.params?.[0]?.height === 'number') {
return c.params[0].height;
}
}
}
if (typeof d.height === 'number') return d.height;
return undefined;
}
export function tiktokPlayerEmbedUrl(id: string): string {
// Pure 9:16 video player. music_info/description default OFF (they'd switch
// TikTok to a wide "video + info panel" layout); controls/progress/play/volume/
// fullscreen buttons all default ON, so only autoplay + rel are load-bearing.
return `https://www.tiktok.com/player/v1/${encodeURIComponent(id)}?autoplay=1&rel=0`;
}
// --- Dailymotion ---------------------------------------------------------- // --- Dailymotion ----------------------------------------------------------
export function getDailymotionId(url: string): string | null { export function getDailymotionId(url: string): string | null {
@@ -161,6 +247,9 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
export function isSoundCloudTrack(url: string): boolean { export function isSoundCloudTrack(url: string): boolean {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
// NOTE: on.soundcloud.com short links are NOT handled here — the w.soundcloud
// widget resolver doesn't follow the redirect; supporting them needs an oEmbed
// round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL.
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false; if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
// /<artist>/<track|sets/set> — at least two segments, not a bare profile // /<artist>/<track|sets/set> — at least two segments, not a bare profile
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
@@ -173,15 +262,18 @@ export function isSoundCloudTrack(url: string): boolean {
// --- Apple Music ---------------------------------------------------------- // --- Apple Music ----------------------------------------------------------
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */ /** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
export function getAppleMusicEmbed(url: string): { embedUrl: string; height: number } | null { export function getAppleMusicEmbed(
url: string,
): { embedUrl: string; height: number; video: boolean } | null {
try { try {
const u = new URL(url); const u = new URL(url);
if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null; if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null;
if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null; if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null;
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`; const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
const video = /\/music-video\//.test(u.pathname);
// A single song (?i=… on an album, or a /song/ link) is compact; collections are tall. // A single song (?i=… on an album, or a /song/ link) is compact; collections are tall.
const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname); const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname);
return { embedUrl, height: isSong ? 175 : 450 }; return { embedUrl, height: isSong ? 175 : 450, video };
} catch { } catch {
return null; return null;
} }
@@ -201,13 +293,134 @@ export function getTweetId(url: string): string | null {
} }
} }
// --- Tidal ----------------------------------------------------------------
export function getTidalEmbed(
url: string,
): { kind: 'audio' | 'landscape'; embedUrl: string; height?: number } | null {
try {
const u = new URL(url);
const h = u.hostname.replace(/^(www|listen|desktop)\./, '');
if (h !== 'tidal.com') return null;
const p = u.pathname.replace(/^\/browse/, '');
let m = p.match(/^\/track\/(\d+)/);
if (m) return { kind: 'audio', embedUrl: `https://embed.tidal.com/tracks/${m[1]}`, height: 120 };
m = p.match(/^\/album\/(\d+)/);
if (m)
// layout=gridify → full-width grid that fills the container (fixes the
// narrow/centered default); ~275px is Tidal's own album embed height.
return {
kind: 'audio',
embedUrl: `https://embed.tidal.com/albums/${m[1]}?layout=gridify`,
height: 275,
};
m = p.match(/^\/playlist\/([0-9a-fA-F-]+)/);
if (m)
return {
kind: 'audio',
embedUrl: `https://embed.tidal.com/playlists/${m[1]}?layout=gridify`,
height: 275,
};
m = p.match(/^\/video\/(\d+)/);
if (m) return { kind: 'landscape', embedUrl: `https://embed.tidal.com/videos/${m[1]}` };
} catch {
/* ignore */
}
return null;
}
// --- Instagram ------------------------------------------------------------
export function getInstagramEmbed(url: string): string | null {
try {
const u = new URL(url);
if (u.hostname !== 'instagram.com' && u.hostname !== 'www.instagram.com') return null;
const m = u.pathname.match(/^\/(p|reel|reels|tv)\/([A-Za-z0-9_-]+)/);
if (!m) return null;
const type = m[1] === 'reels' ? 'reel' : m[1];
return `https://www.instagram.com/${type}/${m[2]}/embed/`;
} catch {
return null;
}
}
// --- Reddit (post embed via redditmedia — bypasses the homeserver's blocked
// preview fetch, which Reddit serves a bot-check "please wait" page to) ---
const REDDIT_EMBED_QS = '?ref_source=embed&ref=share&embed=true&theme=dark';
export function getRedditPostEmbed(url: string): string | null {
try {
const u = new URL(url);
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
// redd.it/<id> short link → reddit.com/comments/<id> (no subreddit needed).
if (h === 'redd.it') {
const id = u.pathname.replace(/^\/+|\/+$/g, '').split('/')[0];
return id ? `https://embed.reddit.com/comments/${id}/${REDDIT_EMBED_QS}` : null;
}
if (h !== 'reddit.com') return null;
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
if (!m) return null;
// embed.reddit.com is the current host (www.redditmedia.com now 301s here).
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/${REDDIT_EMBED_QS}`;
} catch {
return null;
}
}
// --- Loom -----------------------------------------------------------------
export function getLoomId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'loom.com') return null;
const m = pathname.match(/^\/(?:share|embed)\/([A-Za-z0-9]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
export function getKickChannel(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'kick.com') return null;
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
} catch {
return null;
}
}
// --- Bluesky --------------------------------------------------------------
export function getBlueskyEmbed(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'bsky.app' && hostname !== 'www.bsky.app') return null;
const m = pathname.match(/^\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)/);
// authority is a handle or did; embed.bsky.app resolves either.
return m ? `https://embed.bsky.app/embed/${m[1]}/app.bsky.feed.post/${m[2]}` : null;
} catch {
return null;
}
}
// --- Embed-URL builders --------------------------------------------------- // --- Embed-URL builders ---------------------------------------------------
const enc = encodeURIComponent; const enc = encodeURIComponent;
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string): string { export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string, hash?: string): string {
if (provider === 'vimeo') return `https://player.vimeo.com/video/${enc(id)}?autoplay=1`; if (provider === 'vimeo') {
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0`; // dnt=1 = Do Not Track (no non-essential cookies); h={hash} required for unlisted.
return `https://player.vimeo.com/video/${enc(id)}?autoplay=1&dnt=1${
hash ? `&h=${enc(hash)}` : ''
}`;
}
// playsinline=1 keeps iOS Safari from forcing the native fullscreen player.
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0&playsinline=1`;
} }
/** Spotify compact players (track/episode) are short; collections are taller. */ /** Spotify compact players (track/episode) are short; collections are taller. */
@@ -228,24 +441,26 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
if (ytId) if (ytId)
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) }; return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
const vimeoId = getVimeoVideoId(url); const vimeo = getVimeoParts(url);
if (vimeoId) if (vimeo)
return { provider: 'vimeo', kind: 'landscape', embedUrl: buildVideoEmbedUrl('vimeo', vimeoId) };
const tiktokId = getTikTokVideoId(url);
if (tiktokId)
return { return {
provider: 'tiktok', provider: 'vimeo',
kind: 'portrait', kind: 'landscape',
embedUrl: `https://www.tiktok.com/player/v1/${enc(tiktokId)}?music_info=1&description=1&rel=0`, embedUrl: buildVideoEmbedUrl('vimeo', vimeo.id, vimeo.hash),
}; };
// NOTE: TikTok is handled by its own card (TikTokEmbedCard) — short "copy-link"
// URLs (vm.tiktok.com, tiktok.com/t/…) need a client-side oEmbed lookup to
// resolve the video id, which a sync parser can't do. See isTikTokLink below.
const dmId = getDailymotionId(url); const dmId = getDailymotionId(url);
if (dmId) if (dmId)
return { return {
provider: 'dailymotion', provider: 'dailymotion',
kind: 'landscape', kind: 'landscape',
embedUrl: `https://www.dailymotion.com/embed/video/${enc(dmId)}?autoplay=1`, // geo.dailymotion.com is the current player; the old /embed/video path was
// deprecated in Sept 2024.
embedUrl: `https://geo.dailymotion.com/player.html?video=${enc(dmId)}&autoplay=1`,
}; };
const streamableId = getStreamableId(url); const streamableId = getStreamableId(url);
@@ -289,9 +504,38 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
if (apple) if (apple)
return { return {
provider: 'applemusic', provider: 'applemusic',
kind: 'audio', kind: apple.video ? 'landscape' : 'audio',
embedUrl: apple.embedUrl, embedUrl: apple.embedUrl,
height: apple.height, height: apple.video ? undefined : apple.height,
};
const tidal = getTidalEmbed(url);
if (tidal)
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
const insta = getInstagramEmbed(url);
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
const reddit = getRedditPostEmbed(url);
if (reddit) return { provider: 'reddit', kind: 'rich', embedUrl: reddit, height: 480 };
const bsky = getBlueskyEmbed(url);
if (bsky) return { provider: 'bluesky', kind: 'rich', embedUrl: bsky, height: 600 };
const loomId = getLoomId(url);
if (loomId)
return {
provider: 'loom',
kind: 'landscape',
embedUrl: `https://www.loom.com/embed/${enc(loomId)}`,
};
const kick = getKickChannel(url);
if (kick)
return {
provider: 'kick',
kind: 'landscape',
embedUrl: `https://player.kick.com/${enc(kick)}?autoplay=true`,
}; };
return null; return null;