mirror of
https://github.com/stoatchat/for-legacy-web.git
synced 2026-03-07 09:25:27 +00:00
video player and link preview
This commit is contained in:
@@ -29,6 +29,7 @@ import { Reactions } from "./attachments/Reactions";
|
|||||||
import { MessageOverlayBar } from "./bars/MessageOverlayBar";
|
import { MessageOverlayBar } from "./bars/MessageOverlayBar";
|
||||||
import Embed from "./embed/Embed";
|
import Embed from "./embed/Embed";
|
||||||
import InviteList from "./embed/EmbedInvite";
|
import InviteList from "./embed/EmbedInvite";
|
||||||
|
import LinkPreview from "./embed/LinkPreview";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachContext?: boolean;
|
attachContext?: boolean;
|
||||||
@@ -195,6 +196,14 @@ const Message = observer(
|
|||||||
{message.embeds?.map((embed, index) => (
|
{message.embeds?.map((embed, index) => (
|
||||||
<Embed key={index} embed={embed} />
|
<Embed key={index} embed={embed} />
|
||||||
))}
|
))}
|
||||||
|
{/* Fallback LinkPreview if no embeds and message contains URLs */}
|
||||||
|
{!message.embeds?.length && content && (() => {
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||||
|
const urls = content.match(urlRegex);
|
||||||
|
return urls?.slice(0, 3).map((url, index) => (
|
||||||
|
<LinkPreview key={`preview-${index}`} url={url} />
|
||||||
|
));
|
||||||
|
})()}
|
||||||
<Reactions message={message} />
|
<Reactions message={message} />
|
||||||
{(mouseHovering || reactionsOpen) &&
|
{(mouseHovering || reactionsOpen) &&
|
||||||
!replacement && !type_msg &&
|
!replacement && !type_msg &&
|
||||||
|
|||||||
396
src/components/common/messaging/embed/LinkPreview.tsx
Normal file
396
src/components/common/messaging/embed/LinkPreview.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user