import { useEffect, useRef, useState } from 'react'; import { ArrowSquareOut, Play } from '@phosphor-icons/react'; import { useAction } from 'convex/react'; import { api } from '../../../../../convex/_generated/api'; import { usePlatform } from '../../platform'; import { PausedGif } from './PausedGif'; import styles from './LinkEmbed.module.css'; interface UrlPreview { title?: string; description?: string; imageUrl?: string; siteName?: string; imageWidth?: number; imageHeight?: number; } const VIDEO_HOSTS = [ 'youtube.com', 'youtu.be', 'www.youtube.com', 'vimeo.com', 'www.vimeo.com', 'twitch.tv', 'www.twitch.tv', 'dailymotion.com', 'www.dailymotion.com', ]; const DIRECT_VIDEO_EXTS = ['.mp4', '.webm', '.ogg', '.mov']; const DIRECT_IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']; function isDirectMedia(url: string): 'video' | 'image' | null { try { const pathname = new URL(url).pathname.toLowerCase(); if (DIRECT_VIDEO_EXTS.some((ext) => pathname.endsWith(ext))) return 'video'; if (DIRECT_IMAGE_EXTS.some((ext) => pathname.endsWith(ext))) return 'image'; } catch {} return null; } function isVideoUrl(url: string): boolean { try { const hostname = new URL(url).hostname; return VIDEO_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h)); } catch { return false; } } // Module-scope cache so we don't refetch the same URL every re-render. // `null` means "we tried and there's no preview" — we cache that too to // avoid hammering the fetcher for URLs that will never resolve. const previewCache = new Map(); // Natural dimensions learned on first successful onLoad for every // embed image (OG preview image, direct image URL). Populates the // reserved-box aspect-ratio so the next mount of the same URL goes // straight to its real proportions instead of the fixed fallback. const embedImageDimsCache = new Map(); // Fallback box for an embed image whose dimensions we haven't probed // yet. Roughly matches the common OG-image aspect ratio (~1.91:1 for // Twitter/Facebook card images). Fixed-size fallback > fluid fallback // because a wrong fluid ratio shifts height when the real image lands. const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const; // Direct inline video embeds default to 16:9 — most web video ships at // that ratio. A wrong default just means a little blank space above or // below the video, not a scroll jump. const DIRECT_VIDEO_FALLBACK_RATIO = '16 / 9'; function normaliseMetadata(raw: any): UrlPreview | null { if (!raw || typeof raw !== 'object') return null; // Accept a few possible shapes: the Electron preload IPC returns // `{ title, description, image, siteName, url }`; matrix-js-sdk style // returns `{ 'og:title', 'og:description', 'og:image', 'og:site_name' }`. const title = raw.title ?? raw['og:title'] ?? raw.ogTitle ?? undefined; const description = raw.description ?? raw['og:description'] ?? raw.ogDescription ?? undefined; const imageUrl = raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined; const siteName = raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined; // Dimensions come from the Convex action's `imageWidth`/`imageHeight` // fields (parsed from og:image:width / og:image:height on the server). // Fall through a few other naming conventions in case a platform- // native fetcher emits the OG keys verbatim. const pickNum = (v: unknown): number | undefined => { if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v; if (typeof v === 'string') { const n = Number(v); if (Number.isFinite(n) && n > 0) return n; } return undefined; }; const imageWidth = pickNum(raw.imageWidth) ?? pickNum(raw['og:image:width']) ?? pickNum(raw.ogImageWidth); const imageHeight = pickNum(raw.imageHeight) ?? pickNum(raw['og:image:height']) ?? pickNum(raw.ogImageHeight); if (!title && !description && !imageUrl) return null; return { title, description, imageUrl, siteName, imageWidth, imageHeight }; } function useUrlPreview(url: string): UrlPreview | null { const platform = usePlatform(); const fetchPreviewAction = useAction(api.links.fetchPreview); const [preview, setPreview] = useState( previewCache.get(url) ?? null, ); useEffect(() => { if (previewCache.has(url)) { const cached = previewCache.get(url) ?? null; // Same cache-seeding as the fresh-fetch branch below — ensures // the reserved image box is correct even when the preview came // out of the module cache on a re-render. if ( cached?.imageUrl && cached.imageWidth && cached.imageHeight && !embedImageDimsCache.has(cached.imageUrl) ) { embedImageDimsCache.set(cached.imageUrl, { w: cached.imageWidth, h: cached.imageHeight, }); } setPreview(cached); return; } let cancelled = false; (async () => { try { // Prefer the platform-native fetcher when available (Electron // ships one via IPC — no CORS, no round-trip through the // backend). On web, `fetchMetadata` returns null due to CORS, // so we fall through to the Convex Node action which performs // the fetch server-side. let result: UrlPreview | null = null; const fetcher = platform?.links?.fetchMetadata; if (typeof fetcher === 'function') { try { const raw = await fetcher(url); result = normaliseMetadata(raw); } catch { result = null; } } if (!result) { try { const raw = await fetchPreviewAction({ url }); if (!cancelled) result = normaliseMetadata(raw); } catch { result = null; } } if (cancelled) return; previewCache.set(url, result); // Server-provided image dimensions populate the same // cache the onLoad handler updates — so the // reserved box is correct on the very first paint of // the preview card, not only after the image finishes // decoding. Falls through to the onLoad probe if the // server didn't have width/height tags. if ( result?.imageUrl && result.imageWidth && result.imageHeight && !embedImageDimsCache.has(result.imageUrl) ) { embedImageDimsCache.set(result.imageUrl, { w: result.imageWidth, h: result.imageHeight, }); } setPreview(result); } catch { if (!cancelled) previewCache.set(url, null); } })(); return () => { cancelled = true; }; }, [url, platform, fetchPreviewAction]); return preview; } function DirectMediaEmbed({ url, type, onOpenGif, }: { url: string; type: 'video' | 'image'; onOpenGif?: (url: string) => void; }) { const videoRef = useRef(null); const [playing, setPlaying] = useState(false); if (type === 'video') { const handlePlay = () => { if (!videoRef.current) return; videoRef.current.controls = true; void videoRef.current.play(); setPlaying(true); }; // `preload="metadata"` leaves the