feat: add TikTok embed support
This commit is contained in:
@@ -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 }) => {
|
||||||
<VideoContent
|
// Check if there's valid thumbnail metadata
|
||||||
body={body}
|
const hasValidThumbnail =
|
||||||
info={info}
|
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
||||||
{...props}
|
typeof info.thumbnail_info?.mimetype === 'string';
|
||||||
renderThumbnail={
|
|
||||||
mediaAutoLoad
|
return (
|
||||||
? () => (
|
<VideoContent
|
||||||
<ThumbnailContent
|
body={body}
|
||||||
info={info}
|
info={info}
|
||||||
renderImage={(src) => (
|
mimeType={mimeType}
|
||||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
url={url}
|
||||||
)}
|
encInfo={encInfo}
|
||||||
/>
|
{...props}
|
||||||
)
|
renderThumbnail={
|
||||||
: undefined
|
mediaAutoLoad
|
||||||
}
|
? () => hasValidThumbnail ? (
|
||||||
renderVideo={(p) => <Video {...p} />}
|
// Try to load the provided thumbnail first
|
||||||
/>
|
<ThumbnailContent
|
||||||
)}
|
info={info}
|
||||||
|
renderImage={(src) => (
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
renderVideo={(p) => <Video {...p} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
outlined={outlineAttachment}
|
outlined={outlineAttachment}
|
||||||
/>
|
/>
|
||||||
{renderCaption()}
|
{renderCaption()}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
// Check if we have valid thumbnail data
|
||||||
|
const thumbInfo = info.thumbnail_info;
|
||||||
|
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
||||||
|
const hasValidThumbnail = typeof thumbMxcUrl === 'string' && typeof thumbInfo?.mimetype === 'string';
|
||||||
|
|
||||||
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
|
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const thumbInfo = info.thumbnail_info;
|
|
||||||
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
|
||||||
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(() => {
|
||||||
loadThumbSrc();
|
// Only attempt to load thumbnail if we have valid data
|
||||||
}, [loadThumbSrc]);
|
if (hasValidThumbnail) {
|
||||||
|
loadThumbSrc();
|
||||||
|
}
|
||||||
|
}, [hasValidThumbnail, loadThumbSrc]);
|
||||||
|
|
||||||
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
|
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
132
src/app/components/message/content/VideoFrameThumbnail.tsx
Normal file
132
src/app/components/message/content/VideoFrameThumbnail.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
116
src/app/components/url-preview/TikTokEmbed.css.tsx
Normal file
116
src/app/components/url-preview/TikTokEmbed.css.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
245
src/app/components/url-preview/TikTokEmbed.tsx
Normal file
245
src/app/components/url-preview/TikTokEmbed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user