feat: add TikTok embed support
This commit is contained in:
@@ -23,8 +23,9 @@ import {
|
||||
ThumbnailContent,
|
||||
UnsupportedContent,
|
||||
VideoContent,
|
||||
VideoFrameThumbnail,
|
||||
} 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 { ImageViewer } from './image-viewer';
|
||||
import { PdfViewer } from './Pdf-viewer';
|
||||
@@ -66,16 +67,21 @@ export function RenderMessageContent({
|
||||
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
|
||||
if (filteredUrls.length === 0) return undefined;
|
||||
|
||||
// Separate YouTube URLs from other URLs
|
||||
// Separate special URLs from generic ones
|
||||
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 (
|
||||
<>
|
||||
{/* Render YouTube embeds (ad-free via Piped) */}
|
||||
{/* Render YouTube embeds (ad-free via yt-dlp) */}
|
||||
{youtubeUrls.map((url) => (
|
||||
<YouTubeEmbed key={url} url={url} />
|
||||
))}
|
||||
{/* Render TikTok embeds */}
|
||||
{tiktokUrls.map((url) => (
|
||||
<TikTokEmbed key={url} url={url} />
|
||||
))}
|
||||
{/* Render standard URL previews for other links */}
|
||||
{otherUrls.length > 0 && (
|
||||
<UrlPreviewHolder>
|
||||
@@ -227,26 +233,47 @@ export function RenderMessageContent({
|
||||
<MVideo
|
||||
content={getContent()}
|
||||
renderAsFile={renderFile}
|
||||
renderVideoContent={({ body, info, ...props }) => (
|
||||
<VideoContent
|
||||
body={body}
|
||||
info={info}
|
||||
{...props}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => (
|
||||
<ThumbnailContent
|
||||
info={info}
|
||||
renderImage={(src) => (
|
||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
renderVideo={(p) => <Video {...p} />}
|
||||
/>
|
||||
)}
|
||||
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
|
||||
body={body}
|
||||
info={info}
|
||||
mimeType={mimeType}
|
||||
url={url}
|
||||
encInfo={encInfo}
|
||||
{...props}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => hasValidThumbnail ? (
|
||||
// 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}
|
||||
/>
|
||||
{renderCaption()}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
|
||||
const PLAY_TIME_THROTTLE_OPS = {
|
||||
|
||||
@@ -48,14 +48,14 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
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(
|
||||
useCallback(async () => {
|
||||
const thumbInfo = info.thumbnail_info;
|
||||
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
||||
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;
|
||||
// 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;
|
||||
}, [mx, info, useAuthentication])
|
||||
}, [mx, thumbMxcUrl, thumbInfo, info.thumbnail_file, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadThumbSrc();
|
||||
}, [loadThumbSrc]);
|
||||
// Only attempt to load thumbnail if we have valid data
|
||||
if (hasValidThumbnail) {
|
||||
loadThumbSrc();
|
||||
}
|
||||
}, [hasValidThumbnail, loadThumbSrc]);
|
||||
|
||||
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
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 './ImageContent';
|
||||
export * from './VideoContent';
|
||||
export * from './VideoFrameThumbnail';
|
||||
export * from './AudioContent';
|
||||
export * from './FileContent';
|
||||
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 { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { openExternalUrl } from '../../utils/tauri';
|
||||
|
||||
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 }>(
|
||||
({ url, ts, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
@@ -36,10 +49,22 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadPreview().catch(() => {
|
||||
// Error is already handled by AsyncStatus.Error state
|
||||
loadPreview().catch((error) => {
|
||||
// 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;
|
||||
|
||||
@@ -57,8 +82,9 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
return (
|
||||
<>
|
||||
{imgUrl && (
|
||||
<UrlPreviewImg
|
||||
<AuthenticatedUrlPreviewImg
|
||||
src={imgUrl}
|
||||
useAuthentication={useAuthentication}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
style={isSvg ? { padding: '10px' } : undefined}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './UrlPreview';
|
||||
export * from './UrlPreviewCard';
|
||||
export * from './YouTubeEmbed';
|
||||
export * from './TikTokEmbed';
|
||||
|
||||
Reference in New Issue
Block a user