Compare commits

..

6 Commits

Author SHA1 Message Date
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
jared 791b1cb5ea feat(embeds): interactive X/Twitter post embed + Apple Music player
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
X/Twitter: the static OG card was a snapshot — no video, no galleries/threads.
Keep it as the (reliable) facade and add a 'View post' button that loads the
official platform.twitter.com interactive embed on click: playable video/GIF,
image galleries, quote tweets. The embed self-sizes via a scoped postMessage
resize listener (matched to our iframe + the platform.twitter.com origin). Nothing
loads from X until the user clicks, and the link still works if X blocks the frame.

Apple Music: music.apple.com album/playlist/song links now play inline via the
embed.music.apple.com player (compact for a single song, tall for collections),
through the existing MediaEmbedCard audio path.

Pure parsers/builders unit-tested (13 cases). Desktop CSP frame-src adds
platform.twitter.com + embed.music.apple.com (separate commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:14:53 -04:00
jared 6c903fe3e5 feat(embeds): add TikTok, Twitch, Dailymotion, Streamable, Spotify, SoundCloud players
Generalize the inline-embed system: parseMediaEmbed() resolves any supported URL
to an embed spec (provider + kind + embed URL), and a single MediaEmbedCard
renders the media-forward click-to-play facade for all of them — landscape 16:9
(YouTube, Vimeo, Dailymotion, Streamable, Twitch), portrait 9:16 (Shorts, TikTok),
and short fixed-height audio players (Spotify, SoundCloud). Same privacy facade as
before: nothing loads from the third party until the user presses play, gated by
the 'Inline Media Players' setting.

Twitch embeds pass the current page hostname as the required parent param.
Non-embeddable fallbacks (e.g. vm.tiktok.com short links, non-media tweets) keep
their existing rich OG cards; X/Twitter intentionally keeps its rich tweet card.

All parsers/builders are pure + unit-tested (videoEmbed.test.ts, 10 cases).
Desktop Tauri CSP frame-src updated for the new hosts (separate commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:01:05 -04:00
jared 93f307cd63 feat(embeds): inline YouTube/Vimeo players + media-forward video tiles
CI / Build & Quality Checks (push) Successful in 11m17s
CI / Trigger Desktop Build (push) Successful in 10s
Video link tiles (YouTube, Shorts, Vimeo) now play in place instead of only
opening a browser tab. Adds a media-forward 16:9 (9:16 for Shorts) tile with a
privacy-friendly click-to-play facade: the homeserver's cached og:image thumbnail
+ a play button, and only on click does it swap in the cookie-less
youtube-nocookie / player.vimeo iframe — so nothing loads from Google/Vimeo until
the user presses play. Gated by a new 'Inline Media Players' setting (default on);
when off it falls back to a link that opens the video in a new tab.

Also sources YouTube thumbnails from the homeserver og:image instead of
img.youtube.com, which fixes the existing broken YouTube thumbnails on the web
build (nginx img-src has no YouTube host) and removes the pre-click Google request.

Pure URL parsing + embed-URL building moved to utils/videoEmbed.ts (unit-tested).

Note: the desktop app's Tauri CSP frame-src must allow the video hosts (separate
commit in cinny-desktop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:48:12 -04:00
6 changed files with 1198 additions and 271 deletions
@@ -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,
{ {
@@ -197,6 +204,107 @@ export const MediaPlayButton = style([
}, },
]); ]);
// ---------------------------------------------------------------------------
// Inline video embed (YouTube / Vimeo) — media-forward click-to-play facade
// ---------------------------------------------------------------------------
export const EmbedMediaLandscape = style([
DefaultReset,
{
position: 'relative',
width: '100%',
aspectRatio: '16 / 9',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto' },
},
},
]);
export const EmbedMediaPortrait = style([
DefaultReset,
{
position: 'relative',
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 },
},
},
]);
// Fills the aspect box; used for both the <button> facade and the <a> fallback.
export const EmbedFacade = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.85)',
},
},
]);
export const EmbedIframe = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 0,
display: 'block',
},
]);
// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline).
export const EmbedIframeStatic = style([
DefaultReset,
{
width: '100%',
border: 0,
display: 'block',
},
]);
export const EmbedPlaceholder = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#111111',
color: '#ffffff',
fontSize: toRem(28),
},
]);
// Audio embeds (Spotify / SoundCloud) — full-width, short fixed height (set inline).
export const EmbedMediaAudio = style([
DefaultReset,
{
position: 'relative',
width: '100%',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
},
]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon) // Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -315,6 +423,36 @@ export const BadgeTikTok = style({
color: '#ffffff', color: '#ffffff',
}); });
export const BadgeSoundCloud = style({
backgroundColor: '#ff5500',
color: '#ffffff',
});
export const BadgeDailymotion = style({
backgroundColor: '#0066dc',
color: '#ffffff',
});
export const BadgeStreamable = style({
backgroundColor: '#0f90fa',
color: '#ffffff',
});
export const BadgeAppleMusic = style({
backgroundColor: '#fa243c',
color: '#ffffff',
});
export const BadgeInstagram = style({
backgroundColor: '#e1306c',
color: '#ffffff',
});
export const BadgeTidal = style({
backgroundColor: '#000000',
color: '#ffffff',
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Twitch LIVE badge // Twitch LIVE badge
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+452 -271
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay'; import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -17,6 +17,18 @@ import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer'; import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard'; import { onEnterOrSpace } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
getTikTokVideoId,
getTweetId,
isTikTokLink,
MediaEmbed,
parseMediaEmbed,
tiktokIdFromOembed,
tiktokOembedUrl,
tiktokPlayerEmbedUrl,
} from '../../utils/videoEmbed';
const linkStyles = { color: color.Success.Main }; const linkStyles = { color: color.Success.Main };
@@ -25,10 +37,7 @@ const linkStyles = { color: color.Success.Main };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type CardVariant = type CardVariant =
| 'youtube-shorts'
| 'youtube'
| 'tiktok' | 'tiktok'
| 'vimeo'
| 'github' | 'github'
| 'twitter' | 'twitter'
| 'reddit' | 'reddit'
@@ -44,53 +53,6 @@ type CardVariant =
| 'tenor' | 'tenor'
| 'generic'; | 'generic';
function getYouTubeVideoId(url: string): string | null {
try {
const parsed = new URL(url);
const { hostname, pathname, searchParams } = parsed;
// youtu.be/<id>
if (hostname === 'youtu.be') {
const id = pathname.slice(1).split('/')[0];
return id || null;
}
// youtube.com/watch?v=<id>
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
if (pathname === '/watch') {
return searchParams.get('v');
}
// youtube.com/shorts/<id> — handled by isYouTubeShorts
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (shortsMatch) return shortsMatch[1];
}
} catch {
// ignore malformed URLs
}
return null;
}
function isYouTubeShorts(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
} catch {
return false;
}
}
function getYoutubeShortsId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
function isTikTok(url: string): boolean { function isTikTok(url: string): boolean {
try { try {
const { hostname } = new URL(url); const { hostname } = new URL(url);
@@ -111,18 +73,6 @@ function getTikTokUsername(url: string): string | null {
} }
} }
function getVimeoVideoId(url: string): string | null {
try {
const parsed = new URL(url);
const { hostname, pathname } = parsed;
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
function isGitHubRepo(url: string): boolean { function isGitHubRepo(url: string): boolean {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
@@ -332,11 +282,10 @@ function isTenor(url: string): boolean {
} }
function getCardVariant(url: string): CardVariant { function getCardVariant(url: string): CardVariant {
// Shorts must be detected before generic youtube // NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
if (isYouTubeShorts(url)) return 'youtube-shorts'; // upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
if (getYouTubeVideoId(url) !== null) return 'youtube'; // non-embed fallbacks (incl. TikTok short links with no resolvable id).
if (isTikTok(url)) return 'tiktok'; if (isTikTok(url)) return 'tiktok';
if (getVimeoVideoId(url) !== null) return 'vimeo';
if (isGitHubRepo(url)) return 'github'; if (isGitHubRepo(url)) return 'github';
if (isTwitter(url)) return 'twitter'; if (isTwitter(url)) return 'twitter';
if (getRedditSubreddit(url) !== null) return 'reddit'; if (getRedditSubreddit(url) !== null) return 'reddit';
@@ -484,157 +433,7 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string })
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared VideoCard — used by YouTube (non-Shorts) and Vimeo // Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id)
// ---------------------------------------------------------------------------
function VideoCard({
url,
prev,
thumbnailUrl,
siteBadgeLabel,
siteBadgeClass,
showPlayButton,
}: {
url: string;
prev: IPreviewUrlResponse;
thumbnailUrl: string;
siteBadgeLabel: string;
siteBadgeClass: string;
showPlayButton?: boolean;
}) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
return (
<>
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.MediaThumbnailWrapper}
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
>
<img
className={previewCss.MediaThumbnailImg}
src={thumbnailUrl}
alt={title}
loading="lazy"
/>
{showPlayButton !== false && (
<div className={previewCss.MediaPlayOverlay}>
<div className={previewCss.MediaPlayButton}>
<Icon size="400" src={Icons.Play} />
</div>
</div>
)}
</a>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// Card 1: YouTube Shorts
// ---------------------------------------------------------------------------
function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const videoId = getYoutubeShortsId(url) ?? '';
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
const title = prev['og:title'] ?? '';
// YouTube Shorts og:description is often the channel name
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
const channelName = rawDescription.length < 80 ? rawDescription : null;
return (
<>
{/* Header bar with Shorts badge */}
<div className={previewCss.ShortsHeader}>
<SiteBadge label="Shorts" colorClass={previewCss.BadgeYouTubeShorts} />
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
YouTube
</Text>
</div>
{/* Side-by-side layout */}
<div className={previewCss.PortraitSideLayout}>
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.PortraitThumbnail}
aria-label={`Watch Short: ${title}`}
>
<img
className={previewCss.PortraitThumbnailImg}
src={thumbnailSrc}
alt={title}
loading="lazy"
/>
<div className={previewCss.PortraitPlayOverlay}>
<div className={previewCss.PortraitPlayButton}></div>
</div>
</a>
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text
priority="400"
style={{
fontWeight: 700,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</Text>
)}
{channelName && (
<Text size="T200" priority="300" style={{ opacity: 0.65 }}>
{channelName}
</Text>
)}
<Text
size="T200"
priority="300"
style={{ opacity: 0.5, marginTop: 'auto' }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
youtube.com
</Text>
</Box>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Card 2: TikTok
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TikTokCard({ function TikTokCard({
@@ -754,6 +553,61 @@ function TikTokCard({
// Card 3: Twitter / X // Card 3: Twitter / X
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Interactive X/Twitter post embed. The official platform.twitter.com iframe
// renders the full post — playable video/GIF, image galleries, quote tweets —
// and reports its height back to us via postMessage, which we apply so the card
// grows to fit. Scoped to messages from OUR iframe + the platform.twitter.com
// origin. Only mounted after the user clicks "View post" (privacy facade).
function TweetEmbed({ id }: { id: string }) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [height, setHeight] = useState(320);
const theme =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: '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 (
<iframe
ref={iframeRef}
className={previewCss.EmbedIframeStatic}
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
id,
)}&theme=${theme}&dnt=true`}
title="Post on X"
style={{ height: `${height}px` }}
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
/>
);
}
function TwitterCard({ function TwitterCard({
url, url,
prev, prev,
@@ -765,12 +619,16 @@ function TwitterCard({
mx: ReturnType<typeof useMatrixClient>; mx: ReturnType<typeof useMatrixClient>;
useAuthentication: boolean; useAuthentication: boolean;
}) { }) {
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [expanded, setExpanded] = useState(false);
const rawTitle = (prev['og:title'] as string | undefined) ?? ''; const rawTitle = (prev['og:title'] as string | undefined) ?? '';
const rawDescription = (prev['og:description'] as string | undefined) ?? ''; const rawDescription = (prev['og:description'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined; const mxcImage = prev['og:image'] as string | undefined;
const imageWidth = prev['og:image:width'] as number | undefined; const imageWidth = prev['og:image:width'] as number | undefined;
const isTweet = isTwitterTweet(url); const isTweet = isTwitterTweet(url);
const tweetId = getTweetId(url);
const { name, handle, text } = parseTweetTitle(rawTitle); const { name, handle, text } = parseTweetTitle(rawTitle);
const tweetText = text ?? (isTweet ? rawDescription : undefined); const tweetText = text ?? (isTweet ? rawDescription : undefined);
@@ -781,22 +639,39 @@ function TwitterCard({
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false) ? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
: null; : null;
const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
const 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" />
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
</div>
);
// Expanded: the interactive, self-sizing post embed (playable video, gallery…).
if (canEmbed && expanded && tweetId) {
return (
<>
{header}
<UrlPreviewContent>
<TweetEmbed id={tweetId} />
</UrlPreviewContent>
</>
);
}
return ( return (
<> <>
{/* Header row */} {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" />
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
</div>
<UrlPreviewContent> <UrlPreviewContent>
{isTweet && tweetText ? ( {isTweet && tweetText ? (
@@ -819,17 +694,29 @@ function TwitterCard({
/> />
)} )}
<Text <Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
size="T200" <Text
priority="300" size="T200"
style={{ opacity: 0.5 }} priority="300"
as="a" style={{ opacity: 0.5 }}
href={url} as="a"
target="_blank" href={url}
rel="noreferrer" target="_blank"
> rel="noreferrer"
{getDomain(url)} >
</Text> {getDomain(url)}
</Text>
{canEmbed && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => setExpanded(true)}
before={<Icon size="50" src={Icons.Play} />}
>
<Text size="T200">View post</Text>
</Chip>
)}
</Box>
</UrlPreviewContent> </UrlPreviewContent>
</> </>
); );
@@ -1098,33 +985,307 @@ function RedditCard({
// Remaining card variants (unchanged from original) // Remaining card variants (unchanged from original)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { // Media-forward video tile with a privacy-friendly click-to-play facade: shows
const videoId = getYouTubeVideoId(url)!; // the homeserver's cached thumbnail + a play button; only on click does it swap
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; // in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third
// party until the user presses play. Gated by the `inlineMediaEmbeds` setting —
// when off it falls back to a link that opens the video in a new tab.
const EMBED_BADGE: Record<string, { label: string; class: string }> = {
youtube: { label: 'YouTube', class: '' },
vimeo: { label: 'Vimeo', class: previewCss.BadgeVimeo },
tiktok: { label: 'TikTok', class: previewCss.BadgeTikTok },
dailymotion: { label: 'Dailymotion', class: previewCss.BadgeDailymotion },
streamable: { label: 'Streamable', class: previewCss.BadgeStreamable },
twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple },
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
// from the third party until the user presses play. Gated by `inlineMediaEmbeds`
// — when off it falls back to a link that opens the media in a new tab.
function MediaEmbedCard({
url,
prev,
embed,
}: {
url: string;
prev: IPreviewUrlResponse;
embed: MediaEmbed;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const rawTitle = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
const portrait = embed.kind === 'portrait';
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,
mxcImage,
useAuthentication,
portrait ? 320 : 480,
portrait ? 568 : 270,
'scale',
false,
)
: undefined;
// Shorts share the youtube provider but get their own badge.
const badge =
embed.provider === 'youtube' && portrait
? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts }
: (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' });
const mediaClass = fixedHeight
? previewCss.EmbedMediaAudio
: portrait
? previewCss.EmbedMediaPortrait
: previewCss.EmbedMediaLandscape;
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
const thumb = thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
) : (
<span className={previewCss.EmbedPlaceholder}>
<Icon size="600" src={Icons.Play} />
</span>
);
const playOverlay = (
<span className={previewCss.MediaPlayOverlay}>
<span className={previewCss.MediaPlayButton}>
<Icon size="400" src={Icons.Play} />
</span>
</span>
);
const enterFullscreen = () => {
mediaRef.current?.requestFullscreen?.().catch(() => undefined);
};
return ( return (
<VideoCard <Box direction="Column">
url={url} <div ref={mediaRef} className={mediaClass} style={mediaStyle}>
prev={prev} {playing ? (
thumbnailUrl={thumbnailSrc} // No sandbox: these are CSP-allowlisted trusted hosts, and sandboxing
siteBadgeLabel="YouTube" // breaks player features (fullscreen, TikTok's flow, etc.).
siteBadgeClass="" <iframe
/> className={previewCss.EmbedIframe}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
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={`Open on ${badge.label}: ${title}`}
>
{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 && video && (
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
<Text size="T200"> Fullscreen</Text>
</Chip>
)}
</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>
); );
} }
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { // TikTok needs its own card: short "copy-link" URLs (vm.tiktok.com, tiktok.com/t/…)
// Vimeo always includes og:image for video thumbnails // don't carry the video id, and the homeserver's link preview is bot-walled. So we
const thumbnailSrc = (prev['og:image'] as string | undefined) ?? ''; // 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 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);
try {
const res = await fetch(tiktokOembedUrl(url));
const id = tiktokIdFromOembed(await res.json());
if (id) {
setVideoId(id);
setPlaying(true);
} else {
setFailed(true);
}
} catch {
setFailed(true);
} finally {
setResolving(false);
}
}, [url, videoId]);
const enterFullscreen = () => mediaRef.current?.requestFullscreen?.().catch(() => undefined);
const facadeInner = (
<>
{thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} 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 ( return (
<VideoCard <Box direction="Column">
url={url} <div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
prev={prev} {playing && videoId ? (
thumbnailUrl={thumbnailSrc} <iframe
siteBadgeLabel="Vimeo" className={previewCss.EmbedIframe}
siteBadgeClass={previewCss.BadgeVimeo} src={tiktokPlayerEmbedUrl(videoId)}
/> title={title || 'TikTok'}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
allowFullScreen
loading="lazy"
/>
) : inlineMediaEmbeds ? (
<button
type="button"
className={previewCss.EmbedFacade}
onClick={start}
disabled={resolving}
aria-label={`Play TikTok${title ? `: ${title}` : ''}`}
>
{facadeInner}
</button>
) : (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={`Open on TikTok${title ? `: ${title}` : ''}`}
>
{facadeInner}
</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="TikTok" colorClass={previewCss.BadgeTikTok} />
</Text>
{playing && videoId && (
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
<Text size="T200"> Fullscreen</Text>
</Chip>
)}
</Box>
{failed && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Couldnt load this TikTok open it on TikTok.
</Text>
)}
{!(playing && videoId) && title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
</UrlPreviewContent>
</Box>
); );
} }
@@ -1733,18 +1894,38 @@ 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/
// 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);
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);
switch (variant) { switch (variant) {
case 'youtube-shorts':
return <YouTubeShortsCard url={url} prev={prev} />;
case 'youtube':
return <YouTubeCard url={url} prev={prev} />;
case 'tiktok': case 'tiktok':
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />; return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'vimeo':
return <VimeoCard url={url} prev={prev} />;
case 'github': case 'github':
return <GitHubCard url={url} prev={prev} />; return <GitHubCard url={url} prev={prev} />;
case 'twitter': case 'twitter':
@@ -1826,14 +2007,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>
@@ -2250,6 +2250,7 @@ function Messages() {
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [inlineMediaEmbeds, setInlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting( const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
settingsAtom, settingsAtom,
@@ -2344,6 +2345,15 @@ function Messages() {
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />} after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Inline Media Players"
description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
after={
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Show Hidden Events" title="Show Hidden Events"
+2
View File
@@ -182,6 +182,7 @@ export interface Settings {
mediaAutoLoad: boolean; mediaAutoLoad: boolean;
urlPreview: boolean; urlPreview: boolean;
encUrlPreview: boolean; encUrlPreview: boolean;
inlineMediaEmbeds: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's // [MSC1763] Opt-in: permanently redact your OWN messages once a room's
// retention window passes (default off — nothing auto-deletes by surprise). // retention window passes (default off — nothing auto-deletes by surprise).
@@ -290,6 +291,7 @@ const defaultSettings: Settings = {
mediaAutoLoad: true, mediaAutoLoad: true,
urlPreview: true, urlPreview: true,
encUrlPreview: true, encUrlPreview: true,
inlineMediaEmbeds: true,
showHiddenEvents: false, showHiddenEvents: false,
enforceRetentionLocally: false, enforceRetentionLocally: false,
legacyUsernameColor: false, legacyUsernameColor: false,
+199
View File
@@ -0,0 +1,199 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getYouTubeVideoId,
getYoutubeShortsId,
isYouTubeShorts,
getVimeoVideoId,
getTikTokVideoId,
isTikTokLink,
tiktokIdFromOembed,
tiktokPlayerEmbedUrl,
getDailymotionId,
getStreamableId,
getTwitchTarget,
getSpotifyEmbedTarget,
isSoundCloudTrack,
getAppleMusicEmbed,
getTweetId,
getTidalEmbed,
getInstagramEmbed,
getRedditPostEmbed,
buildVideoEmbedUrl,
spotifyEmbedHeight,
parseMediaEmbed,
} from './videoEmbed';
const HOST = 'chat.lotusguild.org';
test('YouTube: watch / youtu.be / embed / shorts', () => {
assert.equal(getYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), '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://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-');
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
});
test('Vimeo', () => {
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
});
test('TikTok: canonical /video/<id> only', () => {
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'), '7234567890123456789');
assert.equal(getTikTokVideoId('https://vm.tiktok.com/ZMabc/'), null); // short link → oEmbed
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.ok(tiktokPlayerEmbedUrl('999').startsWith('https://www.tiktok.com/player/v1/999?autoplay=1'));
});
test('Dailymotion + Streamable', () => {
assert.equal(getDailymotionId('https://www.dailymotion.com/video/x8abcde'), 'x8abcde');
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
});
test('Twitch: channel / video / clip', () => {
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/somestreamer'), {
type: 'channel',
value: 'somestreamer',
});
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/videos/123456789'), {
type: 'video',
value: '123456789',
});
assert.deepEqual(getTwitchTarget('https://clips.twitch.tv/FunnyClipSlug'), {
type: 'clip',
value: 'FunnyClipSlug',
});
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/streamer/clip/CoolSlug'), {
type: 'clip',
value: 'CoolSlug',
});
});
test('Spotify target + height', () => {
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT'), {
type: 'track',
id: '4cOdK2wGLETKBW3PvgPWqT',
});
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/intl-de/album/xyz'), {
type: 'album',
id: 'xyz',
});
assert.equal(getSpotifyEmbedTarget('https://open.spotify.com/'), null);
assert.equal(spotifyEmbedHeight('track'), 152);
assert.equal(spotifyEmbedHeight('album'), 352);
});
test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
});
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
assert.equal(
buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'),
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0',
);
assert.equal(buildVideoEmbedUrl('vimeo', '42'), 'https://player.vimeo.com/video/42?autoplay=1');
});
test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
provider: 'youtube',
kind: 'portrait',
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0',
});
assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape');
assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable');
const spotify = parseMediaEmbed('https://open.spotify.com/track/abc', HOST);
assert.equal(spotify?.kind, 'audio');
assert.equal(spotify?.height, 152);
assert.equal(parseMediaEmbed('https://soundcloud.com/a/b', HOST)?.provider, 'soundcloud');
assert.equal(parseMediaEmbed('https://example.com/x', HOST), null);
});
test('Apple Music: album vs single song height, embed host swap', () => {
const album = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456');
assert.equal(album?.embedUrl, 'https://embed.music.apple.com/us/album/some-album/123456');
assert.equal(album?.height, 450);
const song = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456?i=789');
assert.equal(song?.height, 175);
assert.ok(song?.embedUrl.includes('?i=789'));
assert.equal(getAppleMusicEmbed('https://music.apple.com/us/browse'), null);
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
});
test('getTweetId', () => {
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
assert.equal(getTweetId('https://x.com/user'), null);
assert.equal(getTweetId('https://youtube.com/watch?v=x'), null);
});
test('parseMediaEmbed: Apple Music → audio', () => {
const m = parseMediaEmbed('https://music.apple.com/us/album/x/1?i=2', 'chat.lotusguild.org');
assert.equal(m?.provider, 'applemusic');
assert.equal(m?.kind, 'audio');
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');
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://www.redditmedia.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://www.redditmedia.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
});
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('parseMediaEmbed: Twitch embed carries the parent host', () => {
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
assert.equal(chan?.provider, 'twitch');
assert.ok(chan?.embedUrl.includes('channel=streamer'));
assert.ok(chan?.embedUrl.includes(`parent=${HOST}`));
const clip = parseMediaEmbed('https://clips.twitch.tv/Slug', HOST);
assert.ok(clip?.embedUrl.startsWith('https://clips.twitch.tv/embed?clip=Slug'));
});
+397
View File
@@ -0,0 +1,397 @@
// Pure helpers for detecting embeddable media URLs and building their embed
// URLs. No React/DOM/CSS imports so this stays unit-testable in isolation.
//
// "kind" drives how the card lays the embed out:
// landscape → 16:9 video portrait → 9:16 video
// audio → short fixed-height player (Spotify/SoundCloud/Tidal/Apple Music)
// rich → tall fixed-height post embed (Instagram / Reddit)
export type EmbedKind = 'landscape' | 'portrait' | 'audio' | 'rich';
export type MediaEmbed = {
provider: string;
kind: EmbedKind;
embedUrl: string;
/** Fixed pixel height for audio/rich embeds (aspect-ratio is used for video). */
height?: number;
};
// --- YouTube --------------------------------------------------------------
export function getYouTubeVideoId(url: string): string | null {
try {
const { hostname, pathname, searchParams } = new URL(url);
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
if (pathname === '/watch') return searchParams.get('v');
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
if (embedMatch) return embedMatch[1];
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (shortsMatch) return shortsMatch[1];
}
} catch {
/* ignore */
}
return null;
}
export function isYouTubeShorts(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
} catch {
return false;
}
}
export function getYoutubeShortsId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- Vimeo ----------------------------------------------------------------
export function getVimeoVideoId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- TikTok ---------------------------------------------------------------
/** Canonical /video/<id> links only; short links resolve via oEmbed (below). */
export function getTikTokVideoId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'tiktok.com') return null;
const m = pathname.match(/\/video\/(\d+)/);
return m ? m[1] : null;
} catch {
return 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;
}
export function tiktokPlayerEmbedUrl(id: string): string {
return `https://www.tiktok.com/player/v1/${encodeURIComponent(
id,
)}?autoplay=1&music_info=1&description=1&controls=1&progress_bar=1&play_button=1&volume_control=1&fullscreen_button=1&rel=0`;
}
// --- Dailymotion ----------------------------------------------------------
export function getDailymotionId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h === 'dai.ly') return pathname.slice(1).split('/')[0] || null;
if (h === 'dailymotion.com') {
const m = pathname.match(/^\/video\/([A-Za-z0-9]+)/);
return m ? m[1] : null;
}
} catch {
/* ignore */
}
return null;
}
// --- Streamable -----------------------------------------------------------
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;
} catch {
return null;
}
}
// --- Twitch ---------------------------------------------------------------
export type TwitchTarget =
| { type: 'channel'; value: string }
| { type: 'video'; value: string }
| { type: 'clip'; value: string };
export function getTwitchTarget(url: string): TwitchTarget | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/');
if (h === 'clips.twitch.tv' && parts[0]) return { type: 'clip', value: parts[0] };
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] };
}
} catch {
/* ignore */
}
return null;
}
// --- Spotify --------------------------------------------------------------
const SPOTIFY_TYPES = ['track', 'album', 'playlist', 'episode', 'show', 'artist'] as const;
export type SpotifyType = (typeof SPOTIFY_TYPES)[number];
export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: string } | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'open.spotify.com') return null;
// /intl-xx/track/<id> or /track/<id>
const parts = pathname.replace(/^\/+/, '').split('/');
const idx = parts.findIndex((p) => (SPOTIFY_TYPES as readonly string[]).includes(p));
if (idx === -1 || !parts[idx + 1]) return null;
return { type: parts[idx] as SpotifyType, id: parts[idx + 1].split('?')[0] };
} catch {
return null;
}
}
// --- SoundCloud -----------------------------------------------------------
export function isSoundCloudTrack(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
// /<artist>/<track|sets/set> — at least two segments, not a bare profile
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
return parts.length >= 2;
} catch {
return false;
}
}
// --- Apple Music ----------------------------------------------------------
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
export function getAppleMusicEmbed(url: string): { embedUrl: string; height: number } | null {
try {
const u = new URL(url);
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;
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
// 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);
return { embedUrl, height: isSong ? 175 : 450 };
} catch {
return null;
}
}
// --- X / Twitter ----------------------------------------------------------
export function getTweetId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'twitter.com' && h !== 'x.com' && h !== 'mobile.twitter.com') return null;
const m = pathname.match(/\/status(?:es)?\/(\d+)/);
return m ? m[1] : null;
} catch {
return 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) return { kind: 'audio', embedUrl: `https://embed.tidal.com/albums/${m[1]}`, height: 400 };
m = p.match(/^\/playlist\/([0-9a-fA-F-]+)/);
if (m)
return { kind: 'audio', embedUrl: `https://embed.tidal.com/playlists/${m[1]}`, height: 400 };
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) ---
export function getRedditPostEmbed(url: string): string | null {
try {
const u = new URL(url);
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
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;
return `https://www.redditmedia.com/r/${m[1]}/comments/${m[2]}/?ref_source=embed&ref=share&embed=true&theme=dark`;
} catch {
return null;
}
}
// --- Embed-URL builders ---------------------------------------------------
const enc = encodeURIComponent;
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string): string {
if (provider === 'vimeo') return `https://player.vimeo.com/video/${enc(id)}?autoplay=1`;
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0`;
}
/** Spotify compact players (track/episode) are short; collections are taller. */
export function spotifyEmbedHeight(type: SpotifyType): number {
return type === 'track' || type === 'episode' ? 152 : 352;
}
/**
* Resolve any supported media URL to an embed spec, or null if none applies.
* `host` is the current page hostname — required by Twitch's `parent` param.
*/
export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
const shortsId = getYoutubeShortsId(url);
if (shortsId)
return { provider: 'youtube', kind: 'portrait', embedUrl: buildVideoEmbedUrl('youtube', shortsId) };
const ytId = getYouTubeVideoId(url);
if (ytId)
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
const vimeoId = getVimeoVideoId(url);
if (vimeoId)
return { provider: 'vimeo', kind: 'landscape', embedUrl: buildVideoEmbedUrl('vimeo', vimeoId) };
// 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);
if (dmId)
return {
provider: 'dailymotion',
kind: 'landscape',
embedUrl: `https://www.dailymotion.com/embed/video/${enc(dmId)}?autoplay=1`,
};
const streamableId = getStreamableId(url);
if (streamableId)
return {
provider: 'streamable',
kind: 'landscape',
embedUrl: `https://streamable.com/e/${enc(streamableId)}?autoplay=1`,
};
const twitch = getTwitchTarget(url);
if (twitch) {
const parent = enc(host);
const embedUrl =
twitch.type === 'clip'
? `https://clips.twitch.tv/embed?clip=${enc(twitch.value)}&parent=${parent}&autoplay=true`
: `https://player.twitch.tv/?${twitch.type}=${enc(twitch.value)}&parent=${parent}&autoplay=true`;
return { provider: 'twitch', kind: 'landscape', embedUrl };
}
const spotify = getSpotifyEmbedTarget(url);
if (spotify)
return {
provider: 'spotify',
kind: 'audio',
embedUrl: `https://open.spotify.com/embed/${spotify.type}/${enc(spotify.id)}`,
height: spotifyEmbedHeight(spotify.type),
};
if (isSoundCloudTrack(url))
return {
provider: 'soundcloud',
kind: 'audio',
embedUrl: `https://w.soundcloud.com/player/?url=${enc(
url,
)}&color=%23ff5500&auto_play=true&show_comments=false&hide_related=true`,
height: 166,
};
const apple = getAppleMusicEmbed(url);
if (apple)
return {
provider: 'applemusic',
kind: 'audio',
embedUrl: apple.embedUrl,
height: 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 };
return null;
}