import React, { useCallback, useEffect, useRef, useState } from 'react'; import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { ImageOverlay } from '../ImageOverlay'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview'; import { getIntersectionObserverEntry, useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; import * as css from './UrlPreviewCard.css'; import * as previewCss from './UrlPreview.css'; import { tryDecodeURIComponent } from '../../utils/dom'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { ImageViewer } from '../image-viewer'; import { onEnterOrSpace } from '../../utils/keyboard'; const linkStyles = { color: color.Success.Main }; // --------------------------------------------------------------------------- // Helpers — URL parsing & variant detection // --------------------------------------------------------------------------- type CardVariant = | 'youtube-shorts' | 'youtube' | 'tiktok' | 'vimeo' | 'github' | 'twitter' | 'reddit' | 'spotify' | 'twitch' | 'steam' | 'wikipedia' | 'discord' | 'npm' | 'stackoverflow' | 'imdb' | 'giphy' | 'tenor' | 'generic'; function getYouTubeVideoId(url: string): string | null { try { const parsed = new URL(url); const { hostname, pathname, searchParams } = parsed; // youtu.be/ if (hostname === 'youtu.be') { const id = pathname.slice(1).split('/')[0]; return id || null; } // youtube.com/watch?v= if (hostname === 'www.youtube.com' || hostname === 'youtube.com') { if (pathname === '/watch') { return searchParams.get('v'); } // youtube.com/shorts/ — handled by isYouTubeShorts const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); if (shortsMatch) return shortsMatch[1]; } } catch { // ignore malformed URLs } return null; } function isYouTubeShorts(url: string): boolean { try { const { hostname, pathname } = new URL(url); if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false; return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname); } catch { return false; } } function getYoutubeShortsId(url: string): string | null { try { const { hostname, pathname } = new URL(url); if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null; const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); return m ? m[1] : null; } catch { return null; } } function isTikTok(url: string): boolean { try { const { hostname } = new URL(url); const h = hostname.replace(/^www\./, ''); return h === 'tiktok.com' || h === 'vm.tiktok.com'; } catch { return false; } } function getTikTokUsername(url: string): string | null { try { const { pathname } = new URL(url); const m = pathname.match(/\/((@[^/]+))/); return m ? m[1] : null; } catch { return null; } } function getVimeoVideoId(url: string): string | null { try { const parsed = new URL(url); const { hostname, pathname } = parsed; if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null; const m = pathname.match(/^\/(\d+)/); return m ? m[1] : null; } catch { return null; } } function isGitHubRepo(url: string): boolean { try { const parsed = new URL(url); const { hostname, pathname } = parsed; if (hostname !== 'github.com' && hostname !== 'www.github.com') return false; // Exactly two path segments: // (no deeper pages) const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean); return parts.length === 2; } catch { return false; } } function isTwitter(url: string): boolean { try { const { hostname } = new URL(url); const h = hostname.replace(/^www\./, ''); return h === 'twitter.com' || h === 'x.com'; } catch { return false; } } function isTwitterTweet(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); if (h !== 'twitter.com' && h !== 'x.com') return false; return /\/status\/\d+/.test(pathname); } catch { return false; } } function getRedditInfo(url: string): { subreddit: string | null; isPost: boolean; isUser: boolean; postId: string | null; } { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); if (h !== 'reddit.com' && h !== 'redd.it') { return { subreddit: null, isPost: false, isUser: false, postId: null }; } const subMatch = pathname.match(/^\/r\/([^/]+)/); const subreddit = subMatch ? subMatch[1] : null; const postMatch = pathname.match(/\/comments\/([^/]+)/); const postId = postMatch ? postMatch[1] : null; const userMatch = pathname.match(/^\/(u|user)\/([^/]+)/); return { subreddit, isPost: !!postId, isUser: !!userMatch, postId, }; } catch { return { subreddit: null, isPost: false, isUser: false, postId: null }; } } function getRedditSubreddit(url: string): string | null { return getRedditInfo(url).subreddit; } function getSpotifyType(url: string): string | null { try { const { hostname, pathname } = new URL(url); if (hostname !== 'open.spotify.com') return null; const m = pathname.match(/^\/(track|album|playlist|artist)\//); return m ? m[1] : null; } catch { return null; } } function getTwitchType(url: string): 'live' | 'clip' | 'vod' | null { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); if (h === 'clips.twitch.tv') return 'clip'; if (h !== 'twitch.tv') return null; if (pathname.match(/\/[^/]+\/clip\//)) return 'clip'; if (pathname.match(/\/[^/]+\/v(?:ideos?)?\//)) return 'vod'; // Single path segment = channel const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean); if (parts.length === 1) return 'live'; return null; } catch { return null; } } function getTwitchChannel(url: string): string | null { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); if (h === 'clips.twitch.tv') return null; if (h !== 'twitch.tv') return null; const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean); return parts[0] ?? null; } catch { return null; } } function isTwitch(url: string): boolean { try { const { hostname } = new URL(url); const h = hostname.replace(/^www\./, ''); return h === 'twitch.tv' || h === 'clips.twitch.tv'; } catch { return false; } } function isSteamApp(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); if (h !== 'store.steampowered.com') return false; return pathname.startsWith('/app/'); } catch { return false; } } function isWikipedia(url: string): boolean { try { const { hostname, pathname } = new URL(url); return hostname.endsWith('.wikipedia.org') && pathname.startsWith('/wiki/'); } catch { return false; } } function isDiscordInvite(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); return ( (h === 'discord.gg' || h === 'discord.com') && (pathname.startsWith('/invite/') || h === 'discord.gg') ); } catch { return false; } } function isNpm(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); return h === 'npmjs.com' && pathname.startsWith('/package/'); } catch { return false; } } function isStackOverflow(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); return h === 'stackoverflow.com' && pathname.startsWith('/questions/'); } catch { return false; } } function isImdb(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); return h === 'imdb.com' && pathname.startsWith('/title/'); } catch { return false; } } function isGiphy(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); if (h === 'gph.is') return true; if (h === 'giphy.com' || h === 'media.giphy.com') { return ( pathname.startsWith('/gifs/') || pathname.startsWith('/clips/') || pathname.startsWith('/media/') ); } return false; } catch { return false; } } function isTenor(url: string): boolean { try { const { hostname, pathname } = new URL(url); const h = hostname.replace(/^www\./, ''); return h === 'tenor.com' && pathname.startsWith('/view/'); } catch { return false; } } function getCardVariant(url: string): CardVariant { // Shorts must be detected before generic youtube if (isYouTubeShorts(url)) return 'youtube-shorts'; if (getYouTubeVideoId(url) !== null) return 'youtube'; if (isTikTok(url)) return 'tiktok'; if (getVimeoVideoId(url) !== null) return 'vimeo'; if (isGitHubRepo(url)) return 'github'; if (isTwitter(url)) return 'twitter'; if (getRedditSubreddit(url) !== null) return 'reddit'; if (getSpotifyType(url) !== null) return 'spotify'; if (isTwitch(url)) return 'twitch'; if (isSteamApp(url)) return 'steam'; if (isWikipedia(url)) return 'wikipedia'; if (isDiscordInvite(url)) return 'discord'; if (isNpm(url)) return 'npm'; if (isStackOverflow(url)) return 'stackoverflow'; if (isImdb(url)) return 'imdb'; if (isGiphy(url)) return 'giphy'; if (isTenor(url)) return 'tenor'; return 'generic'; } function getDomain(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; } } // --------------------------------------------------------------------------- // Parsing helpers for individual cards // --------------------------------------------------------------------------- interface ParsedTweet { name: string; handle?: string; text?: string; } function parseTweetTitle(ogTitle: string): ParsedTweet { // Pattern 1: "Name on X: \"tweet text\"" or "Name on Twitter: \"tweet text\"" const onXMatch = ogTitle.match(/^(.+?) on (?:X|Twitter):\s*["""](.+)["""]?$/s); if (onXMatch) return { name: onXMatch[1], text: onXMatch[2] }; // Pattern 2: "X / Name (@handle)" or "Twitter / Name (@handle)" const xSlashMatch = ogTitle.match(/^(?:X|Twitter)\s*\/\s*(.+?)\s*\(@(.+?)\)/); if (xSlashMatch) return { name: xSlashMatch[1], handle: xSlashMatch[2] }; // Pattern 3: "Name (@handle): text" or "Name (@handle)" const handleMatch = ogTitle.match(/^(.+?)\s*\(@(.+?)\)(?::\s*(.+))?$/s); if (handleMatch) return { name: handleMatch[1], handle: handleMatch[2], text: handleMatch[3] }; return { name: ogTitle }; } function parseTikTokTitle(ogTitle: string): { username: string | null; caption: string | null } { // Format 1: "@username on TikTok: "caption"" const fmt1 = ogTitle.match(/^(@\S+)\s+on\s+TikTok:\s*["""](.+)["""]?$/s); if (fmt1) return { username: fmt1[1], caption: fmt1[2] }; // Format 2: "username TikTok | caption" const fmt2 = ogTitle.match(/^(.+?)\s+TikTok\s*\|\s*(.+)$/s); if (fmt2) return { username: fmt2[1], caption: fmt2[2] }; // Format 3: just caption return { username: null, caption: ogTitle || null }; } function extractHashtags(text: string): string[] { const found: string[] = []; const re = /#\w+/g; let m = re.exec(text); while (m !== null && found.length < 5) { found.push(m[0]); m = re.exec(text); } return found; } function parseRedditMeta(ogDescription: string): { author: string | null; upvotes: string | null; comments: string | null; } { let author: string | null = null; let upvotes: string | null = null; let comments: string | null = null; const authorM = ogDescription.match(/Posted\s+by\s+u\/(\S+)/i); if (authorM) author = authorM[1]; const pointsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+(?:points?|upvotes?)/i); if (pointsM) upvotes = pointsM[1]; const commentsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+comments?/i); if (commentsM) comments = commentsM[1]; return { author, upvotes, comments }; } function parseTwitchGame(ogDescription: string): string | null { const m = ogDescription.match(/play(?:ing)?\s+(.+?)(?:\s*[-–—]|\s*\d+\s+viewer|$)/i); return m ? m[1].trim() : null; } // --------------------------------------------------------------------------- // Inline SVG logos // --------------------------------------------------------------------------- function GitHubIcon({ size = 24 }: { size?: number }) { return ( ); } function XLogoIcon({ size = 20 }: { size?: number }) { return ( ); } // --------------------------------------------------------------------------- // Shared badge component // --------------------------------------------------------------------------- function SiteBadge({ label, colorClass }: { label: string; colorClass: string }) { return ( {label} ); } // --------------------------------------------------------------------------- // Shared VideoCard — used by YouTube (non-Shorts) and Vimeo // --------------------------------------------------------------------------- function VideoCard({ url, prev, thumbnailUrl, siteBadgeLabel, siteBadgeClass, showPlayButton, }: { url: string; prev: IPreviewUrlResponse; thumbnailUrl: string; siteBadgeLabel: string; siteBadgeClass: string; showPlayButton?: boolean; }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; return ( <> {title} {showPlayButton !== false && (
)}
{title && ( {title} )} {description && ( {description} )} ); } // --------------------------------------------------------------------------- // Card 1: YouTube Shorts // --------------------------------------------------------------------------- function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const videoId = getYoutubeShortsId(url) ?? ''; const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; const title = prev['og:title'] ?? ''; // YouTube Shorts og:description is often the channel name const rawDescription = (prev['og:description'] as string | undefined) ?? ''; const channelName = rawDescription.length < 80 ? rawDescription : null; return ( <> {/* Header bar with Shorts badge */}
YouTube
{/* Side-by-side layout */}
{title}
{title && ( {title} )} {channelName && ( {channelName} )} youtube.com
); } // --------------------------------------------------------------------------- // Card 2: TikTok // --------------------------------------------------------------------------- function TikTokCard({ url, prev, mx, useAuthentication, }: { url: string; prev: IPreviewUrlResponse; mx: ReturnType; useAuthentication: boolean; }) { const rawTitle = (prev['og:title'] as string | undefined) ?? ''; const rawDescription = (prev['og:description'] as string | undefined) ?? ''; const mxcImage = prev['og:image'] as string | undefined; const thumbSrc = mxcImage ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 160, 284, 'scale', false) : null; const { username: parsedUsername, caption: parsedCaption } = parseTikTokTitle(rawTitle); const usernameFromUrl = getTikTokUsername(url); const username = parsedUsername ?? usernameFromUrl; const caption = parsedCaption ?? rawDescription; // Extract hashtags from caption and description combined const hashtagSource = `${caption ?? ''} ${rawDescription}`; const hashtags = extractHashtags(hashtagSource); // Strip hashtags from caption display const captionDisplay = caption ? caption.replace(/#\w+/g, '').trim() : ''; return ( <> {/* Header */}
{/* TikTok badge with musical note icon */} TikTok {username && ( {username} )} tiktok.com
{/* Body */}
{thumbSrc ? ( {captionDisplay}
) : (
)} {captionDisplay && ( {captionDisplay} )} {hashtags.length > 0 && (
{hashtags.map((tag) => ( {tag} ))}
)}
); } // --------------------------------------------------------------------------- // Card 3: Twitter / X // --------------------------------------------------------------------------- function TwitterCard({ url, prev, mx, useAuthentication, }: { url: string; prev: IPreviewUrlResponse; mx: ReturnType; useAuthentication: boolean; }) { const rawTitle = (prev['og:title'] as string | undefined) ?? ''; const rawDescription = (prev['og:description'] as string | undefined) ?? ''; const mxcImage = prev['og:image'] as string | undefined; const imageWidth = prev['og:image:width'] as number | undefined; const isTweet = isTwitterTweet(url); const { name, handle, text } = parseTweetTitle(rawTitle); const tweetText = text ?? (isTweet ? rawDescription : undefined); // Show media image if og:image is wide (>= 300px or unspecified with a tweet URL) const showMedia = !!mxcImage && (imageWidth === undefined || imageWidth >= 300) && isTweet; const mediaThumbSrc = showMedia ? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false) : null; return ( <> {/* Header row */}
{name} {handle && ( @{handle} )}
{isTweet && tweetText ? (
{tweetText}
) : ( !isTweet && rawDescription && ( {rawDescription} ) )} {mediaThumbSrc && ( {tweetText )} {getDomain(url)}
); } // --------------------------------------------------------------------------- // Card 4: Twitch // --------------------------------------------------------------------------- function TwitchCard({ url, prev, mx, useAuthentication, }: { url: string; prev: IPreviewUrlResponse; mx: ReturnType; useAuthentication: boolean; }) { const title = (prev['og:title'] as string | undefined) ?? ''; const description = (prev['og:description'] as string | undefined) ?? ''; const mxcImage = prev['og:image'] as string | undefined; const twitchType = getTwitchType(url) ?? 'live'; const channel = getTwitchChannel(url); const isLive = twitchType === 'live'; const game = description ? parseTwitchGame(description) : null; const thumbSrc = mxcImage ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false) : null; return ( <> {/* Thumbnail */} {thumbSrc ? ( {title} {isLive ? (
LIVE
) : (
)}
) : ( )} {/* Content */} {channel && ( {channel} )} {isLive && ( LIVE )} {title && ( {title} )} {game && ( 🎮 {game} )} {!game && description && ( {description} )} ); } // --------------------------------------------------------------------------- // Card 5: Reddit // --------------------------------------------------------------------------- function RedditCard({ url, prev, mx, useAuthentication, }: { url: string; prev: IPreviewUrlResponse; mx: ReturnType; useAuthentication: boolean; }) { const title = (prev['og:title'] as string | undefined) ?? ''; const description = (prev['og:description'] as string | undefined) ?? ''; const mxcImage = prev['og:image'] as string | undefined; const { subreddit, isPost, isUser } = getRedditInfo(url); const { author, upvotes, comments } = parseRedditMeta(description); // Only render post thumbnail if we have a real image const thumbSrc = mxcImage && isPost ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 160, 120, 'scale', false) : null; // Text preview: strip "Posted by u/..." metadata prefix const textPreview = description .replace(/Posted\s+by\s+u\/\S+\s*[·•]?\s*/i, '') .replace(/\s*\d+[\d,.]*(k|M)?\s+(points?|upvotes?)\s*[·•]?\s*/gi, '') .replace(/\s*\d+[\d,.]*(k|M)?\s+comments?\s*/gi, '') .trim(); if (isUser) { // Simple user card return ( <> {title && ( {title} )} {description && ( {description} )} ); } return ( {/* Meta header */} {subreddit && r/{subreddit}} {author && ( · Posted by u/{author} )} {/* Main content row */}
{title && ( {title} )} {textPreview && textPreview !== title && ( {textPreview} )} {/* Stats row */}
{upvotes && ▲ {upvotes}} {comments && 💬 {comments}}
{thumbSrc && ( {title} )}
); } // --------------------------------------------------------------------------- // Remaining card variants (unchanged from original) // --------------------------------------------------------------------------- function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const videoId = getYouTubeVideoId(url)!; const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; return ( ); } function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { // Vimeo always includes og:image for video thumbnails const thumbnailSrc = (prev['og:image'] as string | undefined) ?? ''; return ( ); } function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; // GitHub og:title is usually "owner/repo: short description" — split on ': ' const colonIdx = title.indexOf(': '); const repoName = colonIdx !== -1 ? title.slice(0, colonIdx) : title; const repoDesc = colonIdx !== -1 ? title.slice(colonIdx + 2) : description; return ( <> GitHub {repoName && ( {repoName} )} {repoDesc && ( {repoDesc} )} {description && description !== repoDesc && ( {description} )} ); } function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; const artworkUrl = (prev['og:image'] as string | undefined) ?? ''; const spotifyType = getSpotifyType(url) ?? 'track'; const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1); return ( <> {artworkUrl ? ( {title} ) : ( )} {title && ( {title} )} {description && ( {description} )} ); } function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; const thumbnailUrl = (prev['og:image'] as string | undefined) ?? ''; return ( <> {thumbnailUrl ? ( {title} ) : ( )} {title && ( {title} )} {description && ( {description} )} ); } function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const rawDescription = prev['og:description'] ?? ''; const description = rawDescription.length > 200 ? `${rawDescription.slice(0, 200)}…` : rawDescription; return ( <> {/* Wikipedia "W" mark */} {title && ( {title} )} {description && ( {description} )} ); } function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; const iconUrl = (prev['og:image'] as string | undefined) ?? ''; return ( <> {iconUrl ? ( {title} ) : ( {/* Discord logo (simple geometric mark) */} )} Join Server {title && ( {title} )} {description && ( {description} )} ); } function NpmCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; return ( <> {/* npm logo — simple square mark */} {title && ( {title} )} {description && ( {description} )} ); } function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; return ( <> {/* Stack Overflow logo — simplified stack of bars */} {title && ( {title} )} {description && ( {description} )} ); } function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; const posterUrl = (prev['og:image'] as string | undefined) ?? ''; return ( <> {posterUrl ? ( {title} ) : ( )} {title && ( {title} )} {description && ( {description} )} ); } // --------------------------------------------------------------------------- // Card: GIF (Giphy / Tenor) // --------------------------------------------------------------------------- function GifCard({ url, prev, mx, useAuthentication, siteBadgeLabel, siteBadgeClass, }: { url: string; prev: IPreviewUrlResponse; mx: ReturnType; useAuthentication: boolean; siteBadgeLabel: string; siteBadgeClass: string; }) { const title = (prev['og:title'] as string | undefined) ?? ''; const mxcImage = prev['og:image'] as string | undefined; const thumbSrc = mxcImage ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false) : null; // If there's no image, fall back to a generic-style layout if (!thumbSrc) { return ( <> {title && ( {title} )} ); } return ( {/* GIF thumbnail — full width */} {title} GIF {/* Footer row */} {title && ( {title} )} ); } function GenericCard({ url, prev, onOpenViewer, viewer, onCloseViewer, thumbUrl, imgUrl, }: { url: string; prev: IPreviewUrlResponse; onOpenViewer: () => void; viewer: boolean; onCloseViewer: () => void; thumbUrl: string | null; imgUrl: string | null; }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined; const domain = getDomain(url); const faviconSrc = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=16`; return ( <> {thumbUrl && ( onEnterOrSpace(() => onOpenViewer())(evt)} onClick={onOpenViewer} /> )} {imgUrl && ( } /> )} {!thumbUrl && ( )} {siteName ? `${siteName} | ` : ''} {tryDecodeURIComponent(url)} {title && ( {title} )} {description && ( {description} )} ); } // --------------------------------------------------------------------------- // Main UrlPreviewCard component // --------------------------------------------------------------------------- export const UrlPreviewCard = as<'div', { url: string; ts: number }>( ({ url, ts, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [viewer, setViewer] = useState(false); const [previewStatus, loadPreview] = useAsyncCallback( useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]), ); useEffect(() => { loadPreview().catch(() => {}); }, [loadPreview]); if (previewStatus.status === AsyncStatus.Error) return null; const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => { const variant = getCardVariant(url); switch (variant) { case 'youtube-shorts': return ; case 'youtube': return ; case 'tiktok': return ; case 'vimeo': return ; case 'github': return ; case 'twitter': return ( ); case 'reddit': return ; case 'spotify': return ; case 'twitch': return ; case 'steam': return ; case 'wikipedia': return ; case 'discord': return ; case 'npm': return ; case 'stackoverflow': return ; case 'imdb': return ; case 'giphy': return ( ); case 'tenor': return ( ); default: { // Generic fallback — skip empty cards if (!prev['og:title'] && !prev['og:description']) return null; const thumbUrl = mxcUrlToHttp( mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false, ); const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication); return ( setViewer(true)} viewer={viewer} onCloseViewer={() => setViewer(false)} thumbUrl={thumbUrl} imgUrl={imgUrl} /> ); } } }; // Don't render the card wrapper when content is empty (loaded but nothing to show) if (previewStatus.status === AsyncStatus.Success) { const content = renderContent(previewStatus.data); if (content === null) return null; return ( {content} ); } return ( ); }, ); export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { const scrollRef = useRef(null); const backAnchorRef = useRef(null); const frontAnchorRef = useRef(null); const [backVisible, setBackVisible] = useState(true); const [frontVisible, setFrontVisible] = useState(true); const intersectionObserver = useIntersectionObserver( useCallback((entries) => { const backAnchor = backAnchorRef.current; const frontAnchor = frontAnchorRef.current; const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries); const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries); if (backEntry) { setBackVisible(backEntry.isIntersecting); } if (frontEntry) { setFrontVisible(frontEntry.isIntersecting); } }, []), useCallback( () => ({ root: scrollRef.current, rootMargin: '10px', }), [], ), ); useEffect(() => { const backAnchor = backAnchorRef.current; const frontAnchor = frontAnchorRef.current; if (backAnchor) intersectionObserver?.observe(backAnchor); if (frontAnchor) intersectionObserver?.observe(frontAnchor); return () => { if (backAnchor) intersectionObserver?.unobserve(backAnchor); if (frontAnchor) intersectionObserver?.unobserve(frontAnchor); }; }, [intersectionObserver]); const handleScrollBack = () => { const scroll = scrollRef.current; if (!scroll) return; const { offsetWidth, scrollLeft } = scroll; scroll.scrollTo({ left: scrollLeft - offsetWidth / 1.3, behavior: 'smooth', }); }; const handleScrollFront = () => { const scroll = scrollRef.current; if (!scroll) return; const { offsetWidth, scrollLeft } = scroll; scroll.scrollTo({ left: scrollLeft + offsetWidth / 1.3, behavior: 'smooth', }); }; return (
{!backVisible && ( <>
)} {children} {!frontVisible && ( <>
)}
); });