Files
cinny/src/app/components/url-preview/UrlPreviewCard.tsx
T
Lotus CIandClaude Opus 5.5 ba5b1ffe7d
CI / Build & Quality Checks (push) Successful in 3m24s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 9m57s
feat(embeds): fallback for hung embeds and deleted X posts (#200)
Probed how real providers fail before picking signals:
- X renders an EMPTY frame for a deleted/private/suspended post and says
  so only via postMessage `twttr.private.no_results`. The post embed now
  swaps to "This post isn't available…" with an "Open on X" link.
- A hung frame never fires `load`. After 20 s every player (media, rich
  posts, TikTok, Steam widget, X) overlays "This embed is taking too long
  to load" with Retry (remounts the iframe) and "Open on <site>". A late
  `load` clears it.
- Instagram and Bluesky show their own "removed / not found" page, and a
  refused request still fires `load` (browser error page), so neither needs
  or can use a guess. A missing height message is not treated as failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 11:54:16 -04:00

2718 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: /<owner>/<repo> (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 `&quot;` 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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
role="img"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}
function XLogoIcon({ size = 20 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
role="img"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.737-8.847L2 2.25h6.957l4.265 5.64L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Shared badge component
// ---------------------------------------------------------------------------
function SiteBadge({ label, colorClass }: { label: string; colorClass: string }) {
return (
<span className={`${previewCss.SiteBadge} ${colorClass}`} aria-label={label}>
{label}
</span>
);
}
// ---------------------------------------------------------------------------
// 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<typeof useMatrixClient>;
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 */}
<div className={previewCss.ShortsHeader}>
{/* TikTok badge with musical note icon */}
<span className={`${previewCss.SiteBadge} ${previewCss.BadgeTikTok}`}>
<Icon
style={{ color: BRAND_COLORS.tiktok, marginRight: '2px' }}
size="Inherit"
src={Icons.VolumeHigh}
/>
TikTok
</span>
{username && (
<Text size="T200" priority="300" style={{ opacity: 0.75 }}>
{username}
</Text>
)}
<Box grow="Yes" />
<Text
size="T200"
priority="300"
style={{ opacity: 0.45 }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
tiktok.com
</Text>
</div>
{/* Body */}
<div className={previewCss.PortraitSideLayout}>
{thumbSrc ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.PortraitThumbnail}
aria-label={`Watch TikTok: ${caption ?? ''}`}
>
<img
className={previewCss.PortraitThumbnailImg}
src={thumbSrc}
alt={captionDisplay}
loading="lazy"
/>
<div className={previewCss.PortraitPlayOverlay}>
<div className={previewCss.PortraitPlayButton}>
<Icon size="Inherit" src={Icons.Play} />
</div>
</div>
</a>
) : (
<div className={previewCss.PortraitPlaceholder}>
<Icon style={{ color: BRAND_COLORS.tiktok }} size="Inherit" src={Icons.VolumeHigh} />
</div>
)}
<Box grow="Yes" direction="Column" gap="100">
{captionDisplay && (
<Text
size="T300"
priority="400"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{captionDisplay}
</Text>
)}
{hashtags.length > 0 && (
<div>
{hashtags.map((tag) => (
<span key={tag} className={previewCss.HashtagChip}>
{tag}
</span>
))}
</div>
)}
</Box>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// 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<HTMLIFrameElement | null>(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 (
<div className={inFlow ? previewCss.EmbedNoticeStatic : previewCss.EmbedNotice} role="status">
<Text size="T300">{message}</Text>
<Box gap="200" wrap="Wrap" justifyContent="Center">
{onRetry && (
<Chip variant="Secondary" radii="Pill" className={MobileTouchTarget} onClick={onRetry}>
<Text size="T200">Retry</Text>
</Chip>
)}
<Chip
as="a"
href={url}
target="_blank"
rel="noreferrer"
variant="Primary"
radii="Pill"
className={MobileTouchTarget}
>
<Text size="T200">Open on {site}</Text>
</Chip>
</Box>
</div>
);
}
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 (
<EmbedNotice
inFlow
message="This post isn't available. It may have been deleted, or the account is private or suspended."
url={url}
site="X"
/>
);
}
return (
<div style={{ position: 'relative' }}>
<iframe
key={attempt}
ref={ref}
className={previewCss.EmbedIframeStatic}
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
id,
)}&theme=${theme}&dnt=true`}
title="Post on X"
style={{ height: `${height}px` }}
sandbox={EMBED_SANDBOX}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
onLoad={onLoad}
/>
{stalled && (
<EmbedNotice message={EMBED_STALLED_MESSAGE} url={url} site="X" onRetry={retry} />
)}
</div>
);
}
function TwitterCard({
url,
prev,
mx,
useAuthentication,
}: {
url: string;
prev: IPreviewUrlResponse;
mx: ReturnType<typeof useMatrixClient>;
useAuthentication: boolean;
}) {
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [expanded, setExpanded] = useState(false);
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 tweetId = getTweetId(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;
const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
const header = (
<div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate>
{name}
</Text>
{handle && (
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
@{handle}
</Text>
)}
<Box grow="Yes" />
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
</div>
);
// Expanded: the interactive, self-sizing post embed (playable video, gallery…).
if (canEmbed && expanded && tweetId) {
return (
<>
<div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate>
{name}
</Text>
{handle && (
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
@{handle}
</Text>
)}
<Box grow="Yes" />
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</div>
<UrlPreviewContent>
<TweetEmbed id={tweetId} url={url} />
</UrlPreviewContent>
</>
);
}
return (
<>
{header}
<UrlPreviewContent>
{isTweet && tweetText ? (
<div className={previewCss.TweetBlock}>{tweetText}</div>
) : (
!isTweet &&
rawDescription && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{rawDescription}</UrlPreviewDescription>
</Text>
)
)}
{mediaThumbSrc && (
<img
className={previewCss.TweetMediaImg}
src={mediaThumbSrc}
alt={tweetText ?? name}
loading="lazy"
/>
)}
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
size="T200"
priority="300"
style={{ opacity: 0.5 }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
{getDomain(url)}
</Text>
{canEmbed && (
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={() => setExpanded(true)}
before={<Icon size="50" src={Icons.Play} />}
>
<Text size="T200">View post</Text>
</Chip>
)}
</Box>
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// Card 4: Twitch
// ---------------------------------------------------------------------------
function TwitchCard({
url,
prev,
mx,
useAuthentication,
}: {
url: string;
prev: IPreviewUrlResponse;
mx: ReturnType<typeof useMatrixClient>;
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 ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.TwitchThumbnailWrapper}
aria-label={`Watch on Twitch: ${title}`}
>
<img
className={previewCss.TwitchThumbnailImg}
src={thumbSrc}
alt={title}
loading="lazy"
/>
{isLive ? (
<div className={previewCss.TwitchLiveOverlay}>
<div className={previewCss.LiveDot} />
LIVE
</div>
) : (
<div className={previewCss.MediaPlayOverlay}>
<div className={previewCss.MediaPlayButton}>
<Icon size="400" src={Icons.Play} />
</div>
</div>
)}
</a>
) : (
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill={BRAND_COLORS.twitch}
aria-hidden="true"
role="img"
>
<path d="M11.64 5.93h1.43v4.28h-1.43m3.93-4.28H17v4.28h-1.43M7 2L3.43 5.57v12.86h4.28V22l3.58-3.57h2.85L20.57 12V2m-1.43 9.29-2.85 2.85h-2.86l-2.5 2.5v-2.5H7.71V3.43h11.43z" />
</svg>
</Box>
)}
{/* Content */}
<UrlPreviewContent>
<Box alignItems="Center" gap="100" wrap="Wrap">
<SiteBadge label="Twitch" colorClass={previewCss.BadgeTwitch} />
{channel && (
<Text priority="400" style={{ fontWeight: 600 }}>
{channel}
</Text>
)}
{isLive && (
<span className={previewCss.TwitchLiveBadge} style={{ marginLeft: '4px' }}>
LIVE
</span>
)}
</Box>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{game && (
<Text size="T200" priority="300" style={{ opacity: 0.65 }}>
🎮 {game}
</Text>
)}
{!game && description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// Card 5: Reddit
// ---------------------------------------------------------------------------
function RedditCard({
url,
prev,
mx,
useAuthentication,
}: {
url: string;
prev: IPreviewUrlResponse;
mx: ReturnType<typeof useMatrixClient>;
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 (
<>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill={BRAND_COLORS.reddit}
aria-hidden="true"
role="img"
>
<circle cx="12" cy="12" r="12" fill={BRAND_COLORS.reddit} />
<path
fill="#ffffff"
d="M20 12a2 2 0 0 0-2-2 2 2 0 0 0-1.36.54C15.28 9.8 13.72 9.4 12 9.39l.7-3.26 2.23.47a1.4 1.4 0 1 0 .15-.69l-2.5-.52a.25.25 0 0 0-.29.19l-.78 3.65c-1.74.06-3.3.46-4.63 1.17A2 2 0 0 0 4 12a2 2 0 0 0 1.07 1.76 3.5 3.5 0 0 0 0 .49C5.07 16.69 8.27 19 12.07 19s6.97-2.31 6.97-4.75a3.5 3.5 0 0 0 0-.47A2 2 0 0 0 20 12zm-13 2a1 1 0 1 1 2 0 1 1 0 0 1-2 0zm5.59 2.71c-.72.72-2.1.77-2.53.77s-1.82-.05-2.53-.77a.25.25 0 0 1 .35-.35c.46.46 1.48.62 2.18.62s1.72-.16 2.18-.62a.25.25 0 0 1 .35.35zm-.15-1.71a1 1 0 1 1 2 0 1 1 0 0 1-2 0z"
/>
</svg>
</Box>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="Reddit" colorClass={previewCss.BadgeReddit} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
return (
<Box direction="Column" style={{ width: '100%' }}>
{/* Meta header */}
<Box
alignItems="Center"
gap="100"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
paddingTop: config.space.S200,
flexWrap: 'wrap',
}}
>
{subreddit && <span className={previewCss.RedditSubBadge}>r/{subreddit}</span>}
{author && (
<Text size="T200" priority="300" style={{ opacity: 0.65 }}>
· Posted by u/{author}
</Text>
)}
</Box>
{/* Main content row */}
<div className={previewCss.RedditPostLayout}>
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text
priority="400"
style={{
fontWeight: 700,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</Text>
)}
{textPreview && textPreview !== title && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
<UrlPreviewDescription>{textPreview}</UrlPreviewDescription>
</Text>
)}
{/* Stats row */}
<div className={previewCss.RedditMeta}>
{upvotes && <span className={previewCss.RedditUpvote}>▲ {upvotes}</span>}
{comments && (
<span>
<Icon size="Inherit" src={Icons.Message} /> {comments}
</span>
)}
</div>
</Box>
{thumbSrc && (
<img className={previewCss.RedditThumb} src={thumbSrc} alt={title} loading="lazy" />
)}
</div>
</Box>
);
}
// ---------------------------------------------------------------------------
// Remaining card variants (unchanged from original)
// ---------------------------------------------------------------------------
// Media-forward video tile with a privacy-friendly click-to-play facade: shows
// the homeserver's cached thumbnail + a play button; only on click does it swap
// in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third
// party until the user presses play. Gated by the `inlineMediaEmbeds` setting —
// when off it falls back to a link that opens the video in a new tab.
const EMBED_BADGE: Record<string, { label: string; class: string }> = {
youtube: { label: 'YouTube', class: '' },
vimeo: { label: 'Vimeo', class: previewCss.BadgeVimeo },
tiktok: { label: 'TikTok', class: previewCss.BadgeTikTok },
dailymotion: { label: 'Dailymotion', class: previewCss.BadgeDailymotion },
streamable: { label: 'Streamable', class: previewCss.BadgeStreamable },
twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple },
spotify: { label: 'Spotify', class: previewCss.BadgeSpotify },
soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud },
applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic },
tidal: { label: 'TIDAL', class: previewCss.BadgeTidal },
instagram: { label: 'Instagram', class: previewCss.BadgeInstagram },
reddit: { label: 'Reddit', class: previewCss.BadgeReddit },
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
loom: { label: 'Loom', class: previewCss.BadgeLoom },
kick: { label: 'Kick', class: previewCss.BadgeKick },
mixcloud: { label: 'Mixcloud', class: previewCss.BadgeMixcloud },
deezer: { label: 'Deezer', class: previewCss.BadgeDeezer },
};
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
// "please wait for verification" page; don't surface that as the caption.
const looksLikeBotWall = (s: string): boolean =>
/please wait|are you a (human|robot)|verifying|verification|just a moment|attention required/i.test(
s,
);
// One media-forward tile for every embeddable provider (video / portrait / audio).
// Privacy-friendly facade: shows the homeserver's cached thumbnail + a play
// button; only on click does it swap in the provider iframe, so nothing loads
// from the third party until the user presses play. Gated by `inlineMediaEmbeds`
// — when off it falls back to a link that opens the media in a new tab.
function MediaEmbedCard({
url,
prev,
embed,
}: {
url: string;
prev: IPreviewUrlResponse;
embed: MediaEmbed;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(playing);
const rawTitle = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
const portrait = embed.kind === 'portrait';
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
const rich = embed.kind === 'rich';
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
// Instagram/Reddit report their height via postMessage → self-resize.
const resizeOrigins = !rich
? NO_RESIZE_ORIGINS
: embed.provider === 'instagram'
? INSTAGRAM_ORIGINS
: embed.provider === 'bluesky'
? BLUESKY_ORIGINS
: REDDIT_ORIGINS;
// Only subscribe to resize messages while the iframe is actually mounted.
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
playing ? resizeOrigins : NO_RESIZE_ORIGINS,
embed.height ?? 480,
);
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
const mxcImage = prev['og:image'] as string | undefined;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(
mx,
mxcImage,
useAuthentication,
portrait ? 320 : 480,
portrait ? 568 : 270,
'scale',
false,
)
: undefined;
// Shorts share the youtube provider but get their own badge.
const badge =
embed.provider === 'youtube' && portrait
? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts }
: (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' });
const mediaClass = fixedHeight
? previewCss.EmbedMediaAudio
: portrait
? previewCss.EmbedMediaPortrait
: previewCss.EmbedMediaLandscape;
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
const thumb = thumbnailUrl ? (
// alt="" — the parent button/link already carries the accessible name.
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : (
<span className={previewCss.EmbedPlaceholder}>
<Icon size="600" src={Icons.Play} />
</span>
);
const playOverlay = (
<span className={previewCss.MediaPlayOverlay}>
<span className={previewCss.MediaPlayButton}>
<Icon size="400" src={Icons.Play} />
</span>
</span>
);
const enterFullscreen = () => {
mediaRef.current?.requestFullscreen?.().catch(() => undefined);
};
return (
// [Gitea #228] `width: 100%` — UrlPreview is a flex ROW, so this column
// shrink-to-fits its content. The facade's <img> supplied that width, but
// the player <iframe> is absolutely positioned and contributes none, so on
// play the whole embed collapsed to the iframe's ~200px intrinsic size.
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
{playing && rich ? (
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
<div style={{ position: 'relative' }}>
<iframe
key={attempt}
ref={resizeRef}
className={previewCss.EmbedIframeStatic}
src={embed.embedUrl}
title={title || badge.label}
style={{ height: `${resizeHeight}px` }}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
scrolling="no"
onLoad={onLoad}
/>
{stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site={badge.label}
onRetry={retry}
/>
)}
</div>
) : (
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
{playing ? (
<>
<iframe
key={attempt}
className={previewCss.EmbedIframe}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
onLoad={onLoad}
/>
{stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site={badge.label}
onRetry={retry}
/>
)}
</>
) : inlineMediaEmbeds ? (
<button
type="button"
className={previewCss.EmbedFacade}
onClick={() => setPlaying(true)}
aria-label={`Play: ${title || badge.label}`}
>
{thumb}
{playOverlay}
</button>
) : (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
>
{thumb}
{playOverlay}
</a>
)}
</div>
)}
<UrlPreviewContent>
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label={badge.label} colorClass={badge.class} />
</Text>
{playing && (
<Box alignItems="Center" gap="100" shrink="No">
{video && (
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200">⛶ Fullscreen</Text>
</Chip>
)}
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)}
</Box>
{/* While a video plays, drop the title/description so the player isn't
squeezed by text below it. */}
{!(playing && video) && title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{!(playing && video) && description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</Box>
);
}
// TikTok needs its own card: short "copy-link" URLs (vm.tiktok.com, tiktok.com/t/…)
// don't carry the video id, and the homeserver's link preview is bot-walled. So we
// resolve the id client-side via TikTok's CORS-enabled oEmbed API (on click, to
// keep the facade privacy model), then play the player/v1 embed.
function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [videoId, setVideoId] = useState<string | null>(() => getTikTokVideoId(url));
const [playing, setPlaying] = useState(false);
const [resolving, setResolving] = useState(false);
const [failed, setFailed] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const tiktokWatch = useEmbedWatchdog(playing && !!videoId);
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
useEffect(() => () => abortRef.current?.abort(), []);
const mxcImage = prev['og:image'] as string | undefined;
const rawTitle = prev['og:title'] ?? '';
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 320, 568, 'scale', false)
: undefined;
const start = useCallback(async () => {
if (videoId) {
setPlaying(true);
return;
}
setResolving(true);
setFailed(false);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal });
const id = tiktokIdFromOembed(await res.json());
if (controller.signal.aborted) return;
if (id) {
setVideoId(id);
setPlaying(true);
} else {
setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
} finally {
if (!controller.signal.aborted) setResolving(false);
}
}, [url, videoId]);
const enterFullscreen = () => mediaRef.current?.requestFullscreen?.().catch(() => undefined);
const facadeInner = (
<>
{thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : (
<span className={previewCss.EmbedPlaceholder} />
)}
<span className={previewCss.MediaPlayOverlay}>
<span className={previewCss.MediaPlayButton}>
{resolving ? <Spinner size="100" /> : <Icon size="400" src={Icons.Play} />}
</span>
</span>
</>
);
return (
// [Gitea #228] See the video embed above: this column must fill the card's
// flex row, not shrink to the iframe's intrinsic width.
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
{playing && videoId ? (
<>
<iframe
key={tiktokWatch.attempt}
className={previewCss.EmbedIframe}
src={tiktokPlayerEmbedUrl(videoId)}
title={title || 'TikTok'}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
onLoad={tiktokWatch.onLoad}
/>
{tiktokWatch.stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site="TikTok"
onRetry={tiktokWatch.retry}
/>
)}
</>
) : inlineMediaEmbeds ? (
<button
type="button"
className={previewCss.EmbedFacade}
onClick={start}
disabled={resolving}
aria-busy={resolving}
aria-label={resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`}
>
{facadeInner}
</button>
) : (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={`Open on TikTok${title ? `: ${title}` : ''}`}
>
{facadeInner}
</a>
)}
</div>
<UrlPreviewContent>
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
</Text>
{playing && videoId && (
<Box alignItems="Center" gap="100" shrink="No">
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200">⛶ Fullscreen</Text>
</Chip>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)}
</Box>
{failed && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Couldn’t load this TikTok — open it on TikTok.
</Text>
)}
{!(playing && videoId) && title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
</UrlPreviewContent>
</Box>
);
}
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 (
<>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.GitHubIconWrapper}
>
<GitHubIcon size={28} />
</Box>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
GitHub
</Text>
{repoName && (
<Text truncate priority="400">
<b>{repoName}</b>
</Text>
)}
{repoDesc && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{repoDesc}</UrlPreviewDescription>
</Text>
)}
{description && description !== repoDesc && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
// Route through the homeserver like every other card — a raw og:image would
// be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
// defeats the click-to-play facade on a nonstandard one.
const artworkUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
: null;
const spotifyType = getSpotifyType(url) ?? 'track';
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
return (
<>
{artworkUrl ? (
<img className={previewCss.ArtworkThumbnail} src={artworkUrl} alt={title} loading="lazy" />
) : (
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
<Icon
style={{ fontSize: '2rem', color: BRAND_COLORS.spotify }}
size="Inherit"
src={Icons.VolumeHigh}
/>
</Box>
)}
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label={`Spotify ${typeLabel}`} colorClass={previewCss.BadgeSpotify} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// Steam bundle/sub/dlc or other store page — OG card (no per-app widget).
function SteamStoreCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
: null;
return (
<>
{thumbnailUrl ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.MediaThumbnailWrapper}
aria-label={`View on Steam: ${title}`}
>
<img
className={previewCss.MediaThumbnailImg}
src={thumbnailUrl}
alt={title}
loading="lazy"
/>
</a>
) : (
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
<Icon
style={{ fontSize: '1.5rem', color: BRAND_COLORS.steam }}
size="Inherit"
src={Icons.Setting}
/>
</Box>
)}
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// Steam news / announcement post — rich OG card (Steam has no official embed
// widget for announcements): banner + headline + body preview.
function SteamNewsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const bannerUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
: null;
return (
<Box direction="Column" style={{ width: '100%' }}>
{bannerUrl && (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.SteamBannerWrapper}
aria-label={`View on Steam: ${title}`}
>
<img className={previewCss.SteamBannerImg} src={bannerUrl} alt={title} loading="lazy" />
</a>
)}
<UrlPreviewContent>
<Box alignItems="Center" gap="100" wrap="Wrap">
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
Announcement
</Text>
</Box>
{title && (
<Text
priority="400"
style={{
fontWeight: 700,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
<Text
style={linkStyles}
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
View on Steam
</Text>
</UrlPreviewContent>
</Box>
);
}
// Steam store app page — OG header + a click-to-play facade that loads Steam's
// official store-widget iframe (live, region-aware price / discount / Buy on
// Steam). Nothing loads from Steam until the user presses "Show price & store".
function SteamAppCard({
url,
prev,
appId,
}: {
url: string;
prev: IPreviewUrlResponse;
appId: string;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [showWidget, setShowWidget] = useState(false);
const steamWatch = useEmbedWatchdog(showWidget);
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const capsuleUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
: null;
return (
<Box direction="Column" style={{ width: '100%' }}>
{capsuleUrl && (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.SteamBannerWrapper}
aria-label={`View on Steam: ${title}`}
>
<img className={previewCss.SteamBannerImg} src={capsuleUrl} alt={title} loading="lazy" />
</a>
)}
<UrlPreviewContent>
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
{showWidget ? (
<div style={{ position: 'relative' }}>
<iframe
key={steamWatch.attempt}
className={previewCss.SteamWidget}
src={steamWidgetEmbedUrl(appId)}
title={title ? `Steam store: ${title}` : 'Steam store widget'}
sandbox={EMBED_SANDBOX}
loading="lazy"
onLoad={steamWatch.onLoad}
/>
{steamWatch.stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site="Steam"
onRetry={steamWatch.retry}
/>
)}
</div>
) : (
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
store.steampowered.com
</Text>
{inlineMediaEmbeds && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => setShowWidget(true)}
before={<Icon size="50" src={Icons.Setting} />}
>
<Text size="T200">Show price &amp; store</Text>
</Chip>
)}
</Box>
)}
</UrlPreviewContent>
</Box>
);
}
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const target = getSteamTarget(url);
if (target?.kind === 'news') return <SteamNewsCard url={url} prev={prev} />;
if (target?.kind === 'app') return <SteamAppCard url={url} prev={prev} appId={target.appId} />;
return <SteamStoreCard url={url} prev={prev} />;
}
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 (
<>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
{/* Wikipedia "W" mark */}
<svg
width={32}
height={32}
viewBox="0 0 50 50"
fill="currentColor"
aria-hidden="true"
role="img"
>
<path d="M26.5 4a1 1 0 0 0-1 1v.1c-2.1.3-4 1.4-5.3 3.1L12 22.5 5.9 8.7A1 1 0 0 0 5 8H2a1 1 0 1 0 0 2h2.3l7.2 16.9c.2.4.6.7 1 .7h.1c.5 0 .9-.3 1.1-.7l7.1-14.2c.9-1.7 2.4-2.9 4.2-3.4v28.4c-1.3.3-2.5 1-3.4 2H10.5a1 1 0 1 0 0 2h10.6C22 43 23.9 44 26 44h-.5a1 1 0 1 0 0 2H26a9 9 0 0 0 8.9-7.8H45a1 1 0 1 0 0-2H35c-.9-1-2.1-1.7-3.5-2V9.8c1.8.4 3.3 1.7 4.2 3.4l7.1 14.2c.2.4.6.7 1.1.7h.1c.4 0 .8-.3 1-.7L52.7 10H55a1 1 0 1 0 0-2h-3a1 1 0 0 0-.9.7L44 22.5l-8.2-14.3C34.5 5.4 32.6 4.3 30.5 4H27a1 1 0 0 0-.5 0z" />
</svg>
</Box>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="Wikipedia" colorClass={previewCss.BadgeWikipedia} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const iconUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
: null;
return (
<>
{iconUrl ? (
<img className={previewCss.ArtworkThumbnail} src={iconUrl} alt={title} loading="lazy" />
) : (
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
{/* Discord logo (simple geometric mark) */}
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill={BRAND_COLORS.discord}
aria-hidden="true"
role="img"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</Box>
)}
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>
{t('Organisms.UrlPreview.join_server')}
</span>
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
function NpmCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
return (
<>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
{/* npm logo — simple square mark */}
<svg width={32} height={32} viewBox="0 0 18 7" fill="none" aria-hidden="true" role="img">
<rect width="18" height="7" fill={BRAND_COLORS.npm} />
<rect x="1" y="1" width="4" height="5" fill="white" />
<rect x="2" y="1" width="1" height="4" fill={BRAND_COLORS.npm} />
<rect x="6" y="1" width="4" height="5" fill="white" />
<rect x="7" y="1" width="1" height="3" fill={BRAND_COLORS.npm} />
<rect x="11" y="1" width="4" height="4" fill="white" />
<rect x="12" y="1" width="1" height="3" fill={BRAND_COLORS.npm} />
</svg>
</Box>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="npm" colorClass={previewCss.BadgeNpm} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
return (
<>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
{/* Stack Overflow logo — simplified stack of bars */}
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill={BRAND_COLORS.stackOverflow}
aria-hidden="true"
role="img"
>
<path d="M18.986 21.865v-6.404h2.134V24H0v-8.539h2.134v6.404zM3.34 13.232l.415-2.396 9.913 1.73-.415 2.396zm1.241-4.065.832-2.26 9.308 3.424-.832 2.26-9.308-3.424zm2.498-3.993 1.248-2.097 8.17 4.857-1.248 2.097L7.079 5.174zm4.568-3.741 1.665-1.665 6.971 6.971-1.665 1.665-6.971-6.97zm7.559 11.866h-2.134V6.796h2.134z" />
</svg>
</Box>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="Stack Overflow" colorClass={previewCss.BadgeStackOverflow} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const posterUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
: null;
return (
<>
{posterUrl ? (
<img className={previewCss.PosterThumbnail} src={posterUrl} alt={title} loading="lazy" />
) : (
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
<SiteBadge label="IMDb" colorClass={previewCss.BadgeImdb} />
</Box>
)}
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="IMDb" colorClass={previewCss.BadgeImdb} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// Card: GIF (Giphy / Tenor)
// ---------------------------------------------------------------------------
function GifCard({
url,
prev,
mx,
useAuthentication,
siteBadgeLabel,
siteBadgeClass,
}: {
url: string;
prev: IPreviewUrlResponse;
mx: ReturnType<typeof useMatrixClient>;
useAuthentication: boolean;
siteBadgeLabel: string;
siteBadgeClass: string;
}) {
const title = (prev['og:title'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
// A GIF card exists to show a moving GIF, so request the original rather than
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
// `loading="lazy"` below keeps it off the wire until it's near the viewport.
const thumbSrc = mxcImage
? shouldServeGifOriginal(url, prev)
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
: null;
// If there's no image, fall back to a generic-style layout
if (!thumbSrc) {
return (
<>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
</UrlPreviewContent>
</>
);
}
return (
<Box direction="Column" style={{ width: '100%' }}>
{/* GIF thumbnail — full width */}
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.GifThumbnailWrapper}
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
>
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
<span className={previewCss.GifBadge}>GIF</span>
</a>
{/* Footer row */}
<UrlPreviewContent>
<Box alignItems="Center" gap="100" wrap="Wrap">
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
{title && (
<Text truncate priority="400" style={{ fontWeight: 600 }}>
{title}
</Text>
)}
</Box>
</UrlPreviewContent>
</Box>
);
}
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;
// Synapse returns 400 from the thumbnail endpoint when it can't thumbnail a
// cached preview image (e.g. SVG/animated). Fall back to the full image, then
// hide entirely if that also fails — otherwise the card shows a broken image
// and the browser keeps re-requesting the failing thumbnail (console spam).
const [useFullImg, setUseFullImg] = useState(false);
const [imgFailed, setImgFailed] = useState(false);
const displayThumb = useFullImg ? imgUrl : thumbUrl;
const handleImgError = () => {
if (!useFullImg && imgUrl && imgUrl !== thumbUrl) setUseFullImg(true);
else setImgFailed(true);
};
return (
<>
{!imgFailed && displayThumb && (
<UrlPreviewImg
src={displayThumb}
alt={prev['og:title']}
title={prev['og:title']}
loading="lazy"
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
onClick={onOpenViewer}
onError={handleImgError}
/>
)}
{imgUrl && (
<ImageOverlay
src={imgUrl}
alt={prev['og:title']}
viewer={viewer}
requestClose={onCloseViewer}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{(!displayThumb || imgFailed) && (
<Icon
src={Icons.Link}
size="50"
aria-hidden="true"
style={{ marginRight: '4px', verticalAlign: 'text-bottom', opacity: 0.5 }}
/>
)}
{siteName ? `${siteName} | ` : ''}
{tryDecodeURIComponent(url)}
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// 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<IPreviewUrlResponse, Error, []>(
useCallback(() => mx.getUrlPreview(url, ts).then(decodePreviewText), [url, ts, mx]),
);
useEffect(() => {
loadPreview().catch(() => {});
}, [loadPreview]);
if (previewStatus.status === AsyncStatus.Error) return null;
// Interactive embeds (players, tweets) render in a wider, responsive card so
// player chrome / tweet content isn't cramped or clipped.
const embed = parseMediaEmbed(url, window.location.hostname);
// Short "copy-link" links carry no id, so the embed is only resolvable from
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
// the wide layout, not the cramped narrow one.
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
if (embed) return embed;
const ogUrl = prev['og:url'];
return typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null;
};
// A Steam app page renders the official ~646px store-widget iframe, so it
// needs the wide card too.
const steamAppWide = getSteamTarget(url)?.kind === 'app';
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
// in the card flex row; stack them on phones (no-op for the single-column
// embed cards). Desktop keeps the row layout.
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
const buildCardClass = (wide: boolean): string | undefined =>
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
.filter(Boolean)
.join(' ') || undefined;
const renderContent = (
prev: IPreviewUrlResponse,
resolvedEmbed: MediaEmbed | null,
): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
// folds in the og:url fallback for short "copy-link" share URLs.
if (resolvedEmbed) {
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
}
// TikTok video links (incl. short share links) get their own oEmbed-resolving card.
if (isTikTokLink(url)) {
return <TikTokEmbedCard url={url} prev={prev} />;
}
const variant = getCardVariant(url);
switch (variant) {
case 'tiktok':
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'github':
return <GitHubCard url={url} prev={prev} />;
case 'twitter':
return (
<TwitterCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />
);
case 'reddit':
return <RedditCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'spotify':
return <SpotifyCard url={url} prev={prev} />;
case 'twitch':
return <TwitchCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'steam':
return <SteamCard url={url} prev={prev} />;
case 'wikipedia':
return <WikipediaCard url={url} prev={prev} />;
case 'discord':
return <DiscordCard url={url} prev={prev} />;
case 'npm':
return <NpmCard url={url} prev={prev} />;
case 'stackoverflow':
return <StackOverflowCard url={url} prev={prev} />;
case 'imdb':
return <ImdbCard url={url} prev={prev} />;
case 'giphy':
return (
<GifCard
url={url}
prev={prev}
mx={mx}
useAuthentication={useAuthentication}
siteBadgeLabel="Giphy"
siteBadgeClass={previewCss.BadgeGiphy}
/>
);
case 'tenor':
return (
<GifCard
url={url}
prev={prev}
mx={mx}
useAuthentication={useAuthentication}
siteBadgeLabel="Tenor"
siteBadgeClass={previewCss.BadgeTenor}
/>
);
default: {
// Generic fallback — skip empty cards
if (!prev['og:title'] && !prev['og:description']) return null;
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
// Show the original for GIFs so they animate; thumbnailing freezes them.
const thumbUrl = shouldServeGifOriginal(url, prev)
? imgUrl
: mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
return (
<GenericCard
url={url}
prev={prev}
onOpenViewer={() => 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 prev = previewStatus.data;
const resolvedEmbed = resolveEmbed(prev);
const content = renderContent(prev, resolvedEmbed);
if (content === null) return null;
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
// link that resolves to a player still gets the wide layout.
const wide = !!resolvedEmbed || isTwitterTweet(url) || steamAppWide;
return (
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
{content}
</UrlPreview>
);
}
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
return (
<UrlPreview
{...props}
ref={ref}
className={buildCardClass(!!embed || isTwitterTweet(url) || steamAppWide)}
>
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
</UrlPreview>
);
},
);
export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
const scrollRef = useRef<HTMLDivElement>(null);
const backAnchorRef = useRef<HTMLDivElement>(null);
const frontAnchorRef = useRef<HTMLDivElement>(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 (
<Box
direction="Column"
{...props}
ref={ref}
style={{ marginTop: config.space.S200, position: 'relative' }}
>
<Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
<Box shrink="No" alignItems="Center">
<div ref={backAnchorRef} />
{!backVisible && (
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
aria-label="Previous preview"
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={handleScrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
<Box alignItems="Inherit" gap="200">
{children}
{!frontVisible && (
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
aria-label="Next preview"
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={handleScrollFront}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
<div ref={frontAnchorRef} />
</Box>
</Box>
</Scroll>
</Box>
);
});