for-legacy-web/src/components/common/messaging/embed/LinkPreview.tsx

397 lines
16 KiB
TypeScript

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<Partial<LinkPreviewData>> {
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<LinkPreviewData> {
// 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<LinkPreviewData> {
try {
const urlObj = new URL(url);
// Add timeout for metadata fetching
const metadataPromise = fetchMetadata(url);
const timeoutPromise = new Promise<Partial<LinkPreviewData>>((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), 6000);
});
const metadata = await Promise.race([metadataPromise, timeoutPromise]).catch(() => ({} as Partial<LinkPreviewData>));
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<LinkPreviewData | null>(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<LinkPreviewData>((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 (
<div className={classNames(styles.embed, styles.website)} style={{ cursor: 'pointer', maxWidth: '550px', minHeight: '200px', padding: '20px', margin: '10px 0', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', color: 'var(--secondary-foreground)' }}>
Loading preview...
</div>
</div>
</div>
);
}
if (!previewData) {
return null;
}
const handleClick = () => {
if (previewData?.videoId) {
setShowPlayer(!showPlayer);
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
};
return (
<div className={classNames(styles.embed, styles.website)} onClick={handleClick} style={{ cursor: 'pointer', maxWidth: '550px', minHeight: '200px', padding: '20px', margin: '10px 0', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1 }}>
{previewData.siteName && (
<div className={styles.siteinfo}>
{previewData.favicon && (
<img
loading="lazy"
className={styles.favicon}
src={previewData.favicon}
draggable={false}
onError={(e) => (e.currentTarget.style.display = "none")}
/>
)}
<div className={styles.site}>
{previewData.siteName}
</div>
</div>
)}
{previewData.title && (
<span>
<div className={styles.title} style={{ fontSize: '18px', fontWeight: '600', marginBottom: '10px' }}>
{previewData.title}
</div>
</span>
)}
{previewData.description && (
<div className={styles.description} style={{ fontSize: '15px', lineHeight: '1.4', color: 'var(--secondary-foreground)', maxHeight: '70px', overflow: 'hidden' }}>
{previewData.description.length > 300 ? previewData.description.substring(0, 300) + '...' : previewData.description}
{previewData.videoId && !showPlayer && (
<span style={{ marginLeft: '8px', color: 'var(--accent)', fontSize: '12px' }}>
Click to play
</span>
)}
</div>
)}
</div>
{previewData.image && (
<div style={{ width: '100%', marginTop: '12px' }}>
{showPlayer && previewData.videoId ? (
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
<iframe
width="100%"
height="100%"
src={`https://www.youtube-nocookie.com/embed/${previewData.videoId}?autoplay=1&modestbranding=1`}
title="YouTube video player"
frameBorder="0"
allowFullScreen
style={{ borderRadius: '8px' }}
/>
<button
onClick={(e) => {
e.stopPropagation();
setShowPlayer(false);
}}
style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(0, 0, 0, 0.7)',
border: 'none',
color: 'white',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
fontSize: '12px'
}}
>
</button>
</div>
) : (
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', minHeight: '200px' }}>
<img
className={styles.image}
src={previewData.image}
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '8px', display: 'block' }}
onError={(e) => (e.currentTarget.style.display = "none")}
/>
{previewData.videoId && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.8)',
borderRadius: '50%',
width: '70px',
height: '70px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
zIndex: 10
}}
onClick={(e) => {
e.stopPropagation();
setShowPlayer(true);
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(0, 0, 0, 0.9)';
e.currentTarget.style.transform = 'translate(-50%, -50%) scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(0, 0, 0, 0.8)';
e.currentTarget.style.transform = 'translate(-50%, -50%) scale(1)';
}}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="white"
style={{ marginLeft: '4px' }}
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
)}
</div>
)}
</div>
)}
</div>
);
});