import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config, } from 'folds'; import { ImageOverlay } from '../ImageOverlay'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { decodeHtmlEntities } from '../../utils/htmlEntities'; 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'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { EMBED_LOAD_TIMEOUT_MS, extractEmbedHeight, getSteamTarget, getTikTokVideoId, getTweetId, isTikTokLink, isTwitterNoResults, MediaEmbed, parseMediaEmbed, steamWidgetEmbedUrl, tiktokIdFromOembed, tiktokOembedUrl, tiktokPlayerEmbedUrl, } from '../../utils/videoEmbed'; const linkStyles = { color: color.Success.Main }; // --------------------------------------------------------------------------- // Provider brand identity colors. // These are official brand-palette values (logos, badges), NOT theme colors, // so they intentionally stay as fixed hex and must not become --lt-* TDS vars. // --------------------------------------------------------------------------- const BRAND_COLORS = { tiktok: '#EE1D52', spotify: '#1db954', steam: '#c7d5e0', twitch: '#9146ff', reddit: '#ff4500', discord: '#5865f2', npm: '#cb3837', stackOverflow: '#f48024', } as const; // --------------------------------------------------------------------------- // Helpers — URL parsing & variant detection // --------------------------------------------------------------------------- type CardVariant = | 'tiktok' | 'github' | 'twitter' | 'reddit' | 'spotify' | 'twitch' | 'steam' | 'wikipedia' | 'discord' | 'npm' | 'stackoverflow' | 'imdb' | 'giphy' | 'tenor' | 'generic'; 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 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; } } // Keep these hosts + the /status(es) pattern in sync with getTweetId // (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has // an extractable id but never routes to the Twitter card or "View post" embed. const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']); function isTwitter(url: string): boolean { try { const { hostname } = new URL(url); return TWITTER_HOSTS.has(hostname.replace(/^www\./, '')); } catch { return false; } } function isTwitterTweet(url: string): boolean { try { const { hostname, pathname } = new URL(url); if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false; return /\/status(?:es)?\/\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 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; } } // Synapse's thumbnailer flattens animated images to a single still frame, so a // GIF served from /thumbnail renders but never plays. Detect GIF previews so the // card can point at /download (the original) instead. /** Text fields that are rendered as-is by the cards below. */ const PREVIEW_TEXT_KEYS = ['og:title', 'og:description', 'og:site_name'] as const; /** * Decode character references left in preview text once, before any card * reads it (e.g. Steam double-encodes, so `"` survived Synapse's decode * and showed literally). #187 DP17. */ const decodePreviewText = (prev: IPreviewUrlResponse): IPreviewUrlResponse => { const out: IPreviewUrlResponse = { ...prev }; PREVIEW_TEXT_KEYS.forEach((key) => { const value = out[key]; if (typeof value === 'string') out[key] = decodeHtmlEntities(value); }); return out; }; function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean { if (prev['og:image:type'] === 'image/gif') return true; try { return new URL(url).pathname.toLowerCase().endsWith('.gif'); } catch { return false; } } // Ceiling on the /download upgrade below: a self-hosted GIF can be hundreds of // MB, and unlike a thumbnail it is served unscaled. Past the cap we keep the // (frozen) thumbnail — the card still links out, so the GIF is one click away. const GIF_ORIGINAL_MAX_BYTES = 10 * 1024 * 1024; // Should this preview's image be fetched whole (so it animates) rather than // thumbnailed? Size is advisory: Synapse usually reports it, and when it's // absent we prefer a working animation over a hypothetical huge file. function shouldServeGifOriginal(url: string, prev: IPreviewUrlResponse): boolean { if (!isGifPreview(url, prev)) return false; const size = prev['matrix:image:size']; return typeof size !== 'number' || size <= GIF_ORIGINAL_MAX_BYTES; } function getCardVariant(url: string): CardVariant { // NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled // upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the // non-embed fallbacks (incl. TikTok short links with no resolvable id). if (isTikTok(url)) return 'tiktok'; 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 (getSteamTarget(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} ); } // --------------------------------------------------------------------------- // Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id) // --------------------------------------------------------------------------- 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 // --------------------------------------------------------------------------- // Origins allowed to drive a self-resizing embed (stable refs → effect deps). const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com']; const INSTAGRAM_ORIGINS = ['https://www.instagram.com']; const REDDIT_ORIGINS = ['https://embed.reddit.com']; const BLUESKY_ORIGINS = ['https://embed.bsky.app']; const NO_RESIZE_ORIGINS: string[] = []; const EMBED_MAX_HEIGHT = 1400; // Defense-in-depth on top of the CSP frame-src allowlist. Critically OMITS // allow-top-navigation, so a compromised embed can't redirect the whole app // (phishing). Fullscreen still works — it's gated by allow=, not a sandbox token. const EMBED_SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation'; // Variable-height iframe embeds (Twitter/Instagram/Reddit) report their content // height via postMessage; listen — scoped to the allowed origins + OUR iframe — // and grow to fit. `origins` must be a stable reference. function useIframeAutoHeight(origins: string[], initial: number) { const ref = useRef(null); const [height, setHeight] = useState(initial); useEffect(() => { if (origins.length === 0) return undefined; const onMessage = (e: MessageEvent) => { if (!origins.includes(e.origin)) return; if (ref.current && e.source !== ref.current.contentWindow) return; let data: unknown = e.data; if (typeof data === 'string') { try { data = JSON.parse(data); } catch { return; } } const h = extractEmbedHeight(data); if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), EMBED_MAX_HEIGHT)); }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [origins]); return { ref, height }; } // Embed iframes that never fire `load` (provider down, network stall) would // otherwise sit as an empty box forever. After EMBED_LOAD_TIMEOUT_MS the card // offers Retry / "Open on …"; a late `load` clears the notice on its own. // `attempt` is the iframe's `key`, so Retry remounts it. function useEmbedWatchdog(active: boolean) { const [attempt, setAttempt] = useState(0); const [loaded, setLoaded] = useState(false); const [timedOut, setTimedOut] = useState(false); useEffect(() => { setLoaded(false); setTimedOut(false); if (!active) return undefined; const t = window.setTimeout(() => setTimedOut(true), EMBED_LOAD_TIMEOUT_MS); return () => window.clearTimeout(t); }, [active, attempt]); const onLoad = useCallback(() => setLoaded(true), []); const retry = useCallback(() => setAttempt((a) => a + 1), []); return { attempt, onLoad, stalled: active && timedOut && !loaded, retry }; } function EmbedNotice({ message, url, site, onRetry, inFlow, }: { message: string; url: string; site: string; onRetry?: () => void; /** Render in the layout flow instead of as an overlay on the player box. */ inFlow?: boolean; }) { return (
{message} {onRetry && ( Retry )} Open on {site}
); } const EMBED_STALLED_MESSAGE = 'This embed is taking too long to load.'; // Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets. // Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade). function TweetEmbed({ id, url }: { id: string; url: string }) { const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320); const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(true); // A deleted/private/suspended post renders an EMPTY frame; X only says so // via `twttr.private.no_results`. const [unavailable, setUnavailable] = useState(false); useEffect(() => { const onMessage = (e: MessageEvent) => { if (!TWITTER_ORIGINS.includes(e.origin)) return; if (!ref.current || e.source !== ref.current.contentWindow) return; if (isTwitterNoResults(e.data)) setUnavailable(true); }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [ref]); const theme = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; if (unavailable) { return ( ); } return (