diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index e32c33d6..8238bb31 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -29,6 +29,7 @@ import { Reactions } from "./attachments/Reactions"; import { MessageOverlayBar } from "./bars/MessageOverlayBar"; import Embed from "./embed/Embed"; import InviteList from "./embed/EmbedInvite"; +import LinkPreview from "./embed/LinkPreview"; interface Props { attachContext?: boolean; @@ -195,6 +196,14 @@ const Message = observer( {message.embeds?.map((embed, index) => ( ))} + {/* 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) => ( + + )); + })()} {(mouseHovering || reactionsOpen) && !replacement && !type_msg && diff --git a/src/components/common/messaging/embed/LinkPreview.tsx b/src/components/common/messaging/embed/LinkPreview.tsx new file mode 100644 index 00000000..edcce22a --- /dev/null +++ b/src/components/common/messaging/embed/LinkPreview.tsx @@ -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> { + 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 ? ( +
+