diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index c29628e..49fdf00 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -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) => ( ))} + {/* Render TikTok embeds */} + {tiktokUrls.map((url) => ( + + ))} {/* Render standard URL previews for other links */} {otherUrls.length > 0 && ( @@ -227,26 +233,47 @@ export function RenderMessageContent({ ( - ( - ( - - )} - /> - ) - : undefined - } - renderVideo={(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 ( + hasValidThumbnail ? ( + // Try to load the provided thumbnail first + ( + + )} + /> + ) : ( + // Fallback: Extract first frame from the video itself + ( + + )} + /> + ) + : undefined + } + renderVideo={(p) => } + /> + ); + }} outlined={outlineAttachment} /> {renderCaption()} diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 38912aa..a3695d9 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -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 = { diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index ca6cbbe..c67e12a 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -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; } diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index d56e1b4..5bd6e16 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -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'; diff --git a/src/app/components/message/content/VideoFrameThumbnail.tsx b/src/app/components/message/content/VideoFrameThumbnail.tsx new file mode 100644 index 0000000..5e5b074 --- /dev/null +++ b/src/app/components/message/content/VideoFrameThumbnail.tsx @@ -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(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; +} diff --git a/src/app/components/message/content/index.ts b/src/app/components/message/content/index.ts index 6a31ed7..6e7e0ef 100644 --- a/src/app/components/message/content/index.ts +++ b/src/app/components/message/content/index.ts @@ -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'; diff --git a/src/app/components/url-preview/TikTokEmbed.css.tsx b/src/app/components/url-preview/TikTokEmbed.css.tsx new file mode 100644 index 0000000..7513143 --- /dev/null +++ b/src/app/components/url-preview/TikTokEmbed.css.tsx @@ -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', + }, +]); diff --git a/src/app/components/url-preview/TikTokEmbed.tsx b/src/app/components/url-preview/TikTokEmbed.tsx new file mode 100644 index 0000000..00c33d7 --- /dev/null +++ b/src/app/components/url-preview/TikTokEmbed.tsx @@ -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) => { + 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({ 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 ( + + + + TikTok - Loading... + + + + + + + ); + } + + // Error state - show basic link + if (state.status === 'error') { + return ( + + + + TikTok Video + + + + ); + } + + // Loaded state - show thumbnail or embed + if (!showEmbed) { + return ( + + + + + {state.title} + + + {state.authorName} + + + + setShowEmbed(true)} + aria-label="Load TikTok video" + > + {state.thumbnailUrl && ( + + )} + + + ); + } + + // Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML) + return ( + + + + {state.title} + + + + + ); +}); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index dfe1bef..9c7245d 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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) => { } }; +/** + * Preview image component with authentication support + */ +const AuthenticatedUrlPreviewImg = ({ + src, + useAuthentication, + ...props +}: React.ComponentProps & { useAuthentication: boolean }) => { + const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication); + return ; +}; + 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 && ( -