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 { 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'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { extractEmbedHeight, getTikTokVideoId, getTweetId, isTikTokLink, MediaEmbed, parseMediaEmbed, tiktokIdFromOembed, tiktokOembedUrl, tiktokPlayerEmbedUrl, } from '../../utils/videoEmbed'; const linkStyles = { color: color.Success.Main }; // --------------------------------------------------------------------------- // 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; } } 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 { // 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 (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} ); } // --------------------------------------------------------------------------- // 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 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 }; } // Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets. // Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade). function TweetEmbed({ id }: { id: string }) { const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320); const theme = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; return (