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

@@ -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 = {

View File

@@ -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;
}

View File

@@ -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';

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 './ImageContent';
export * from './VideoContent';
export * from './VideoFrameThumbnail';
export * from './AudioContent';
export * from './FileContent';
export * from './FallbackContent';