import { memo } from "preact/compat"; import { useEffect, useState } from "preact/hooks"; import styles from "./Embed.module.scss"; import classNames from "classnames"; interface LinkPreviewData { title?: string; description?: string; image?: string; url: string; siteName?: string; favicon?: string; videoId?: string; // For YouTube videos } interface Props { url: string; } // YouTube URL detector const YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/; // Simple URL detector const URL_REGEX = /(https?:\/\/[^\s]+)/g; // Function to fetch metadata from URL with timeout async function fetchMetadata(url: string): Promise> { try { // Use a CORS proxy service to fetch metadata with timeout const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 8000); // 8 second timeout const response = await fetch(proxyUrl, { signal: controller.signal, headers: { 'Accept': 'application/json', } }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); if (!data.contents) return {}; const html = data.contents; const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Extract Open Graph or meta tags const getMetaContent = (property: string, attribute = 'property') => { const element = doc.querySelector(`meta[${attribute}="${property}"]`); return element?.getAttribute('content') || ''; }; const title = getMetaContent('og:title') || getMetaContent('twitter:title') || doc.querySelector('title')?.textContent || ''; const description = getMetaContent('og:description') || getMetaContent('twitter:description') || getMetaContent('description', 'name') || ''; const image = getMetaContent('og:image') || getMetaContent('twitter:image') || ''; const siteName = getMetaContent('og:site_name') || ''; return { title: title.trim(), description: description.trim(), image: image, siteName: siteName || new URL(url).hostname }; } catch (error) { console.warn('Failed to fetch metadata for:', url, error); return {}; } } function extractYouTubeId(url: string): string | null { const match = url.match(YOUTUBE_REGEX); return match ? match[1] : null; } async function createYouTubePreview(videoId: string, url: string): Promise { // For YouTube, use a reliable fallback approach // YouTube often blocks metadata fetching, so we'll use their oEmbed API try { const oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout for YouTube const response = await fetch(oEmbedUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (response.ok) { const data = await response.json(); return { title: data.title || "YouTube Video", description: `by ${data.author_name || 'YouTube'} • Click to watch`, image: data.thumbnail_url || `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`, url, siteName: "YouTube", favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png", videoId: videoId }; } } catch (error) { console.warn('YouTube oEmbed failed:', error); } // Fallback to basic preview return { title: "YouTube Video", description: "Click to watch on YouTube", image: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`, url, siteName: "YouTube", favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png", videoId: videoId }; } async function createGenericPreview(url: string): Promise { try { const urlObj = new URL(url); // Add timeout for metadata fetching const metadataPromise = fetchMetadata(url); const timeoutPromise = new Promise>((_, reject) => { setTimeout(() => reject(new Error('Timeout')), 6000); }); const metadata = await Promise.race([metadataPromise, timeoutPromise]).catch(() => ({} as Partial)); return { title: metadata?.title || urlObj.hostname, description: metadata?.description || "Click to open link", image: metadata?.image, url, siteName: metadata?.siteName || urlObj.hostname, favicon: `${urlObj.protocol}//${urlObj.hostname}/favicon.ico` }; } catch { return { title: "External Link", description: "Click to open link", url, siteName: "External Site" }; } } export default memo(function LinkPreview({ url }: Props) { const [previewData, setPreviewData] = useState(null); const [loading, setLoading] = useState(false); const [showPlayer, setShowPlayer] = useState(false); useEffect(() => { if (!url) return; let cancelled = false; setLoading(true); const loadPreview = async () => { try { console.log('Loading preview for:', url); // Check if it's a YouTube link const youtubeId = extractYouTubeId(url); let preview: LinkPreviewData; if (youtubeId) { console.log('Detected YouTube video:', youtubeId); // For YouTube, try oEmbed first, but don't wait too long try { preview = await Promise.race([ createYouTubePreview(youtubeId, url), new Promise((resolve) => { setTimeout(() => resolve({ title: "YouTube Video", description: "Click to watch on YouTube", image: `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`, url, siteName: "YouTube", favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png", videoId: youtubeId }), 3000); // 3 second fallback }) ]); } catch { // Immediate fallback for YouTube preview = { title: "YouTube Video", description: "Click to watch on YouTube", image: `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`, url, siteName: "YouTube", favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png", videoId: youtubeId }; } } else { console.log('Loading generic preview for:', url); preview = await createGenericPreview(url); } console.log('Preview loaded:', preview); if (!cancelled) { setPreviewData(preview); setLoading(false); } } catch (error) { console.warn('Failed to load preview for:', url, error); if (!cancelled) { // Fallback to basic preview const urlObj = new URL(url); setPreviewData({ title: urlObj.hostname, description: "Click to open link", url, siteName: urlObj.hostname }); setLoading(false); } } }; loadPreview(); return () => { cancelled = true; }; }, [url]); if (loading) { return (
Loading preview...
); } if (!previewData) { return null; } const handleClick = () => { if (previewData?.videoId) { setShowPlayer(!showPlayer); } else { window.open(url, '_blank', 'noopener,noreferrer'); } }; return (
{previewData.siteName && (
{previewData.favicon && ( (e.currentTarget.style.display = "none")} /> )}
{previewData.siteName}
)} {previewData.title && (
{previewData.title}
)} {previewData.description && (
{previewData.description.length > 300 ? previewData.description.substring(0, 300) + '...' : previewData.description} {previewData.videoId && !showPlayer && ( • Click to play )}
)}
{previewData.image && (
{showPlayer && previewData.videoId ? (