feat: add TikTok embed support

This commit is contained in:
2026-03-24 00:14:53 +11:00
parent 4f1179d5cd
commit a8eb404ff3
10 changed files with 589 additions and 36 deletions

View File

@@ -23,8 +23,9 @@ import {
ThumbnailContent, ThumbnailContent,
UnsupportedContent, UnsupportedContent,
VideoContent, VideoContent,
VideoFrameThumbnail,
} from './message'; } from './message';
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl } from './url-preview'; import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl } from './url-preview';
import { Image, MediaControl, Video } from './media'; import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer'; import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer'; import { PdfViewer } from './Pdf-viewer';
@@ -66,16 +67,21 @@ export function RenderMessageContent({
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []); filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
if (filteredUrls.length === 0) return undefined; if (filteredUrls.length === 0) return undefined;
// Separate YouTube URLs from other URLs // Separate special URLs from generic ones
const youtubeUrls = filteredUrls.filter(isYouTubeUrl); const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url)); const tiktokUrls = filteredUrls.filter(isTikTokUrl);
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url));
return ( return (
<> <>
{/* Render YouTube embeds (ad-free via Piped) */} {/* Render YouTube embeds (ad-free via yt-dlp) */}
{youtubeUrls.map((url) => ( {youtubeUrls.map((url) => (
<YouTubeEmbed key={url} url={url} /> <YouTubeEmbed key={url} url={url} />
))} ))}
{/* Render TikTok embeds */}
{tiktokUrls.map((url) => (
<TikTokEmbed key={url} url={url} />
))}
{/* Render standard URL previews for other links */} {/* Render standard URL previews for other links */}
{otherUrls.length > 0 && ( {otherUrls.length > 0 && (
<UrlPreviewHolder> <UrlPreviewHolder>
@@ -227,26 +233,47 @@ export function RenderMessageContent({
<MVideo <MVideo
content={getContent()} content={getContent()}
renderAsFile={renderFile} renderAsFile={renderFile}
renderVideoContent={({ body, info, ...props }) => ( renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
// Check if there's valid thumbnail metadata
const hasValidThumbnail =
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
typeof info.thumbnail_info?.mimetype === 'string';
return (
<VideoContent <VideoContent
body={body} body={body}
info={info} info={info}
mimeType={mimeType}
url={url}
encInfo={encInfo}
{...props} {...props}
renderThumbnail={ renderThumbnail={
mediaAutoLoad mediaAutoLoad
? () => ( ? () => hasValidThumbnail ? (
// Try to load the provided thumbnail first
<ThumbnailContent <ThumbnailContent
info={info} info={info}
renderImage={(src) => ( renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" /> <Image alt={body} title={body} src={src} loading="lazy" />
)} )}
/> />
) : (
// Fallback: Extract first frame from the video itself
<VideoFrameThumbnail
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
) )
: undefined : undefined
} }
renderVideo={(p) => <Video {...p} />} renderVideo={(p) => <Video {...p} />}
/> />
)} );
}}
outlined={outlineAttachment} outlined={outlineAttachment}
/> />
{renderCaption()} {renderCaption()}

View File

@@ -22,6 +22,7 @@ import {
downloadMedia, downloadMedia,
mxcUrlToHttp, mxcUrlToHttp,
} from '../../../utils/matrix'; } from '../../../utils/matrix';
import { getCurrentAccessToken } from '../../../utils/auth';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
const PLAY_TIME_THROTTLE_OPS = { const PLAY_TIME_THROTTLE_OPS = {

View File

@@ -48,14 +48,14 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [thumbSrcState, loadThumbSrc] = useAsyncCallback( // Check if we have valid thumbnail data
useCallback(async () => {
const thumbInfo = info.thumbnail_info; const thumbInfo = info.thumbnail_info;
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url; const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
const hasValidThumbnail = typeof thumbMxcUrl === 'string' && typeof thumbInfo?.mimetype === 'string';
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(async () => {
const encInfo = info.thumbnail_file; const encInfo = info.thumbnail_file;
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
throw new Error('Failed to load thumbnail');
}
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl; const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
// Always use current session's token to avoid stale tokens during account switches // Always use current session's token to avoid stale tokens during account switches
@@ -75,12 +75,15 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
} }
return mediaUrl; return mediaUrl;
}, [mx, info, useAuthentication]) }, [mx, thumbMxcUrl, thumbInfo, info.thumbnail_file, useAuthentication])
); );
useEffect(() => { useEffect(() => {
// Only attempt to load thumbnail if we have valid data
if (hasValidThumbnail) {
loadThumbSrc(); loadThumbSrc();
}, [loadThumbSrc]); }
}, [hasValidThumbnail, loadThumbSrc]);
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null; return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
} }

View File

@@ -30,6 +30,7 @@ import {
downloadMedia, downloadMedia,
mxcUrlToHttp, mxcUrlToHttp,
} from '../../../utils/matrix'; } from '../../../utils/matrix';
import { getCurrentAccessToken } from '../../../utils/auth';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash'; import { validBlurHash } from '../../../utils/blurHash';

View File

@@ -0,0 +1,132 @@
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getCurrentAccessToken } from '../../../utils/auth';
/**
* Extracts the first frame from a video file and renders it as a thumbnail.
* This is used as a fallback when no thumbnail metadata is provided.
*/
export type VideoFrameThumbnailProps = {
mimeType: string;
url: string;
encInfo?: EncryptedAttachmentInfo;
renderImage: (src: string) => ReactNode;
};
export function VideoFrameThumbnail({
mimeType,
url,
encInfo,
renderImage,
}: VideoFrameThumbnailProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null);
const [videoSrcState, loadVideoSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo), accessToken
)
: await downloadMedia(mediaUrl, accessToken);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
useEffect(() => {
loadVideoSrc();
}, [loadVideoSrc]);
useEffect(() => {
if (videoSrcState.status !== AsyncStatus.Success) return undefined;
const videoUrl = videoSrcState.status === AsyncStatus.Success ? videoSrcState.data : null;
if (!videoUrl) return undefined;
const video = document.createElement('video');
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.src = videoUrl;
let isCleanedUp = false;
const handlers: {
loadedData?: () => void;
seeked?: () => void;
error?: () => void;
} = {};
const cleanup = () => {
if (isCleanedUp) return;
isCleanedUp = true;
if (handlers.loadedData) video.removeEventListener('loadeddata', handlers.loadedData);
if (handlers.seeked) video.removeEventListener('seeked', handlers.seeked);
if (handlers.error) video.removeEventListener('error', handlers.error);
video.src = '';
video.load();
};
handlers.loadedData = () => {
try {
video.currentTime = Math.min(0.1, video.duration / 2);
} catch {
cleanup();
}
};
handlers.seeked = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
cleanup();
return;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (blob) {
const thumbnailUrl = URL.createObjectURL(blob);
setThumbnailSrc(thumbnailUrl);
}
cleanup();
}, 'image/jpeg', 0.85);
} catch {
cleanup();
}
};
handlers.error = () => {
cleanup();
};
video.addEventListener('loadeddata', handlers.loadedData);
video.addEventListener('seeked', handlers.seeked);
video.addEventListener('error', handlers.error);
return () => {
cleanup();
if (thumbnailSrc) {
URL.revokeObjectURL(thumbnailSrc);
}
URL.revokeObjectURL(videoUrl);
};
}, [videoSrcState, thumbnailSrc]);
return thumbnailSrc ? renderImage(thumbnailSrc) : null;
}

View File

@@ -1,6 +1,7 @@
export * from './ThumbnailContent'; export * from './ThumbnailContent';
export * from './ImageContent'; export * from './ImageContent';
export * from './VideoContent'; export * from './VideoContent';
export * from './VideoFrameThumbnail';
export * from './AudioContent'; export * from './AudioContent';
export * from './FileContent'; export * from './FileContent';
export * from './FallbackContent'; export * from './FallbackContent';

View File

@@ -0,0 +1,116 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const TikTokEmbed = style([
DefaultReset,
{
width: toRem(360),
maxWidth: '100%',
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
overflow: 'hidden',
},
]);
export const TikTokEmbedHeader = style([
DefaultReset,
{
padding: `${config.space.S200} ${config.space.S300}`,
backgroundColor: color.SurfaceVariant.ContainerActive,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: config.space.S200,
},
]);
export const TikTokEmbedInfo = style([
DefaultReset,
{
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
flex: 1,
minWidth: 0,
},
]);
export const TikTokEmbedTitle = style({
color: color.Primary.Main,
textDecoration: 'none',
fontWeight: config.fontWeight.W500,
':hover': {
textDecoration: 'underline',
},
});
export const TikTokEmbedAuthor = style({
color: color.SurfaceVariant.OnContainer,
textDecoration: 'none',
opacity: 0.8,
':hover': {
textDecoration: 'underline',
opacity: 1,
},
});
export const TikTokEmbedLink = style({
color: color.Success.Main,
textDecoration: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
':hover': {
textDecoration: 'underline',
},
});
export const TikTokThumbnailContainer = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: toRem(550),
backgroundColor: '#000',
display: 'block',
},
]);
export const TikTokThumbnailButton = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: toRem(550),
border: 'none',
padding: 0,
cursor: 'pointer',
backgroundColor: '#000',
display: 'block',
overflow: 'hidden',
},
]);
export const TikTokThumbnail = style([
DefaultReset,
{
width: '100%',
height: '100%',
display: 'block',
objectFit: 'cover',
objectPosition: 'center',
},
]);
export const TikTokEmbedContainer = style([
DefaultReset,
{
width: '100%',
height: toRem(550),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
},
]);

View File

@@ -0,0 +1,245 @@
import React, { MouseEvent, useEffect, useState } from 'react';
import { Box, Spinner, Text, as } from 'folds';
import * as css from './TikTokEmbed.css';
import { openExternalUrl } from '../../utils/tauri';
/**
* Regex patterns to match TikTok URLs
*/
const TIKTOK_URL_PATTERNS = [
// Standard tiktok.com/@username/video/VIDEO_ID
/(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@[\w.-]+\/video\/(\d+)/i,
// Short vm.tiktok.com/VIDEO_CODE
/(?:https?:\/\/)?(?:www\.)?vm\.tiktok\.com\/([\w]+)/i,
// Short vt.tiktok.com/VIDEO_CODE
/(?:https?:\/\/)?(?:www\.)?vt\.tiktok\.com\/([\w]+)/i,
// Mobile m.tiktok.com
/(?:https?:\/\/)?(?:www\.)?m\.tiktok\.com\/v\/(\d+)/i,
];
/**
* TikTok oEmbed API base
*/
const TIKTOK_OEMBED_API = 'https://www.tiktok.com/oembed';
/**
* Click handler for external links - uses Tauri shell on desktop/mobile
*/
const handleExternalLinkClick = (e: MouseEvent<HTMLAnchorElement>) => {
const { href } = e.currentTarget;
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
e.preventDefault();
openExternalUrl(href);
}
};
/**
* Checks if a URL is a TikTok URL
* @param url The URL to check
* @returns True if the URL is a TikTok URL
*/
export function isTikTokUrl(url: string): boolean {
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
}
/**
* Fetches TikTok video metadata using oEmbed API
* @param url The TikTok video URL
* @returns Video metadata including title, author, and thumbnail
*/
async function fetchTikTokOEmbed(url: string): Promise<{
title: string;
authorName: string;
authorUrl: string;
thumbnailUrl: string;
embedHtml: string;
} | null> {
try {
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl);
if (!response.ok) return null;
const data = await response.json();
return {
title: data.title || 'TikTok Video',
authorName: data.author_name || 'Unknown',
authorUrl: data.author_url || url,
thumbnailUrl: data.thumbnail_url || '',
embedHtml: data.html || '',
};
} catch {
return null;
}
}
type TikTokEmbedState =
| { status: 'loading' }
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string }
| { status: 'error' };
type TikTokEmbedProps = {
/** The original TikTok URL */
url: string;
};
/**
* TikTok embed component that shows video preview with metadata
*/
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
const [showEmbed, setShowEmbed] = useState(false);
// Fetch video info on mount
useEffect(() => {
let cancelled = false;
fetchTikTokOEmbed(url).then((info) => {
if (cancelled) return;
if (info) {
setState({
status: 'loaded',
title: info.title,
authorName: info.authorName,
authorUrl: info.authorUrl,
thumbnailUrl: info.thumbnailUrl,
embedHtml: info.embedHtml,
});
} else {
setState({ status: 'error' });
}
});
return () => {
cancelled = true;
};
}, [url]);
// Loading state
if (state.status === 'loading') {
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedLink}
onClick={handleExternalLinkClick}
truncate
>
TikTok - Loading...
</Text>
</div>
<Box
className={css.TikTokThumbnailContainer}
alignItems="Center"
justifyContent="Center"
style={{ background: '#000', display: 'flex' }}
>
<Spinner variant="Secondary" size="600" />
</Box>
</Box>
);
}
// Error state - show basic link
if (state.status === 'error') {
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedLink}
onClick={handleExternalLinkClick}
truncate
>
TikTok Video
</Text>
</div>
</Box>
);
}
// Loaded state - show thumbnail or embed
if (!showEmbed) {
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<div className={css.TikTokEmbedInfo}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T300"
className={css.TikTokEmbedTitle}
onClick={handleExternalLinkClick}
truncate
>
{state.title}
</Text>
<Text
as="a"
href={state.authorUrl}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedAuthor}
onClick={handleExternalLinkClick}
truncate
>
{state.authorName}
</Text>
</div>
</div>
<button
type="button"
className={css.TikTokThumbnailButton}
onClick={() => setShowEmbed(true)}
aria-label="Load TikTok video"
>
{state.thumbnailUrl && (
<img
src={state.thumbnailUrl}
alt={state.title}
className={css.TikTokThumbnail}
crossOrigin="anonymous"
/>
)}
</button>
</Box>
);
}
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML)
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedLink}
onClick={handleExternalLinkClick}
truncate
>
{state.title}
</Text>
</div>
<div
className={css.TikTokEmbedContainer}
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
/>
</Box>
);
});

View File

@@ -12,6 +12,7 @@ import * as css from './UrlPreviewCard.css';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { openExternalUrl } from '../../utils/tauri'; import { openExternalUrl } from '../../utils/tauri';
const linkStyles = { color: color.Success.Main }; const linkStyles = { color: color.Success.Main };
@@ -27,6 +28,18 @@ const handleExternalLinkClick = (e: MouseEvent<HTMLAnchorElement>) => {
} }
}; };
/**
* Preview image component with authentication support
*/
const AuthenticatedUrlPreviewImg = ({
src,
useAuthentication,
...props
}: React.ComponentProps<typeof UrlPreviewImg> & { useAuthentication: boolean }) => {
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
return <UrlPreviewImg src={authenticatedSrc} {...props} />;
};
export const UrlPreviewCard = as<'div', { url: string; ts: number }>( export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
({ url, ts, ...props }, ref) => { ({ url, ts, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -36,10 +49,22 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
); );
useEffect(() => { useEffect(() => {
loadPreview().catch(() => { loadPreview().catch((error) => {
// Error is already handled by AsyncStatus.Error state // Log preview errors for debugging - common causes:
// - 502/503: Matrix server's URL preview service is down/misconfigured
// - 404: URL preview endpoint not available on this server
// - Network errors: connectivity issues
if (error?.httpStatus === 502 || error?.httpStatus === 503) {
// Server's URL preview backend is down - this is a server-side issue
// eslint-disable-next-line no-console
console.warn('[UrlPreview] Server URL preview service unavailable (502/503):', url);
} else if (error?.httpStatus === 404) {
// Server doesn't support URL previews
// eslint-disable-next-line no-console
console.info('[UrlPreview] Server does not support URL previews:', url);
}
}); });
}, [loadPreview]); }, [loadPreview, url]);
if (previewStatus.status === AsyncStatus.Error) return null; if (previewStatus.status === AsyncStatus.Error) return null;
@@ -57,8 +82,9 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
return ( return (
<> <>
{imgUrl && ( {imgUrl && (
<UrlPreviewImg <AuthenticatedUrlPreviewImg
src={imgUrl} src={imgUrl}
useAuthentication={useAuthentication}
alt={prev['og:title']} alt={prev['og:title']}
title={prev['og:title']} title={prev['og:title']}
style={isSvg ? { padding: '10px' } : undefined} style={isSvg ? { padding: '10px' } : undefined}

View File

@@ -1,3 +1,4 @@
export * from './UrlPreview'; export * from './UrlPreview';
export * from './UrlPreviewCard'; export * from './UrlPreviewCard';
export * from './YouTubeEmbed'; export * from './YouTubeEmbed';
export * from './TikTokEmbed';