fix(embeds): resolve TikTok short links via oEmbed + og:url fallback
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>
This commit is contained in:
@@ -19,7 +19,16 @@ 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 {
|
||||||
|
getTikTokVideoId,
|
||||||
|
getTweetId,
|
||||||
|
isTikTokLink,
|
||||||
|
MediaEmbed,
|
||||||
|
parseMediaEmbed,
|
||||||
|
tiktokIdFromOembed,
|
||||||
|
tiktokOembedUrl,
|
||||||
|
tiktokPlayerEmbedUrl,
|
||||||
|
} from '../../utils/videoEmbed';
|
||||||
|
|
||||||
const linkStyles = { color: color.Success.Main };
|
const linkStyles = { color: color.Success.Main };
|
||||||
|
|
||||||
@@ -1150,6 +1159,136 @@ function MediaEmbedCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 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 (
|
||||||
|
<Box direction="Column">
|
||||||
|
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
|
||||||
|
{playing && videoId ? (
|
||||||
|
<iframe
|
||||||
|
className={previewCss.EmbedIframe}
|
||||||
|
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 }}>
|
||||||
|
Couldn’t load this TikTok — open it on TikTok.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!(playing && videoId) && title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
@@ -1764,8 +1903,22 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
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/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
||||||
if (embed) {
|
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
|
||||||
return <MediaEmbedCard url={url} prev={prev} embed={embed} />;
|
// 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);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
isYouTubeShorts,
|
isYouTubeShorts,
|
||||||
getVimeoVideoId,
|
getVimeoVideoId,
|
||||||
getTikTokVideoId,
|
getTikTokVideoId,
|
||||||
|
isTikTokLink,
|
||||||
|
tiktokIdFromOembed,
|
||||||
|
tiktokPlayerEmbedUrl,
|
||||||
getDailymotionId,
|
getDailymotionId,
|
||||||
getStreamableId,
|
getStreamableId,
|
||||||
getTwitchTarget,
|
getTwitchTarget,
|
||||||
@@ -40,10 +43,26 @@ test('Vimeo', () => {
|
|||||||
|
|
||||||
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.ok(tiktokPlayerEmbedUrl('999').startsWith('https://www.tiktok.com/player/v1/999?autoplay=1'));
|
||||||
|
});
|
||||||
|
|
||||||
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');
|
||||||
@@ -104,8 +123,6 @@ test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
|
|||||||
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0',
|
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://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');
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function getVimeoVideoId(url: string): string | 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);
|
||||||
@@ -84,6 +84,42 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ----------------------------------------------------------
|
// --- Dailymotion ----------------------------------------------------------
|
||||||
|
|
||||||
export function getDailymotionId(url: string): string | null {
|
export function getDailymotionId(url: string): string | null {
|
||||||
@@ -289,13 +325,9 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
if (vimeoId)
|
if (vimeoId)
|
||||||
return { provider: 'vimeo', kind: 'landscape', embedUrl: buildVideoEmbedUrl('vimeo', vimeoId) };
|
return { provider: 'vimeo', kind: 'landscape', embedUrl: buildVideoEmbedUrl('vimeo', vimeoId) };
|
||||||
|
|
||||||
const tiktokId = getTikTokVideoId(url);
|
// NOTE: TikTok is handled by its own card (TikTokEmbedCard) — short "copy-link"
|
||||||
if (tiktokId)
|
// URLs (vm.tiktok.com, tiktok.com/t/…) need a client-side oEmbed lookup to
|
||||||
return {
|
// resolve the video id, which a sync parser can't do. See isTikTokLink below.
|
||||||
provider: 'tiktok',
|
|
||||||
kind: 'portrait',
|
|
||||||
embedUrl: `https://www.tiktok.com/player/v1/${enc(tiktokId)}?music_info=1&description=1&rel=0`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const dmId = getDailymotionId(url);
|
const dmId = getDailymotionId(url);
|
||||||
if (dmId)
|
if (dmId)
|
||||||
|
|||||||
Reference in New Issue
Block a user