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 { useSetting } from '../../state/hooks/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 };
|
||||
|
||||
@@ -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 }) {
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
@@ -1764,8 +1903,22 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
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.
|
||||
if (embed) {
|
||||
return <MediaEmbedCard url={url} prev={prev} embed={embed} />;
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user