Files
cinny/src/app/components/url-preview/UrlPreviewCard.tsx
T
jared a52c9e12a4 fix(embeds): quality pass — close button, focus, a11y, perf, SoundCloud revert
From the quality-review agents:
- Revert on.soundcloud.com support: the w.soundcloud widget doesn't follow the
  redirect (needs an oEmbed resolve, deferred).
- Add a Close button to playing video/TikTok embeds and a Collapse button to the
  expanded X post — playback was previously one-way (only escapable by scrolling).
- focus-visible ring on the embed facade (folds resets outline:none, leaving
  keyboard users with no indicator).
- Only subscribe to resize postMessages while the iframe is mounted (was attaching
  a global listener per Instagram/Reddit facade before play).
- TikTok oEmbed fetch now uses AbortController (abort on unmount) + aria-busy /
  'Loading…' label on the resolving spinner.
- Decorative facade thumbnails use alt="" (parent already names them); drop the
  dangling-colon aria-labels when there's no title.
- Cap URL previews at 6 per message so a link-dump can't spawn dozens of fetches.

Tests 728. No CSP change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:45:51 -04:00

2245 lines
71 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 { 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: /<owner>/<repo> (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 (
<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}`}>
<span style={{ color: '#EE1D52', marginRight: '2px' }}></span>
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}></div>
</div>
</a>
) : (
<div className={previewCss.PortraitPlaceholder}>
<span style={{ color: '#EE1D52' }}></span>
</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 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 };
}
// 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 (
<iframe
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"
/>
);
}
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"
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</div>
<UrlPreviewContent>
<TweetEmbed id={tweetId} />
</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"
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="#9146ff"
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="#ff4500"
aria-hidden="true"
role="img"
>
<circle cx="12" cy="12" r="12" fill="#ff4500" />
<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>💬 {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 },
};
// 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 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
: 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 (
<Box direction="Column">
{playing && rich ? (
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
<iframe
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"
/>
) : (
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
{playing ? (
<iframe
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"
/>
) : 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"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
)}
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
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);
// 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 (
<Box direction="Column">
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
{playing && videoId ? (
<iframe
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"
/>
) : 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"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
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 }}>
Couldnt 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 title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
const spotifyType = getSpotifyType(url) ?? 'track';
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
return (
<>
{artworkUrl ? (
<img className={previewCss.ArtworkThumbnail} src={artworkUrl} alt={title} loading="lazy" />
) : (
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.IconWrapper}
>
<span style={{ fontSize: '2rem', color: '#1db954' }}></span>
</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>
</>
);
}
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
return (
<>
{thumbnailUrl ? (
<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}
>
<span style={{ fontSize: '1.5rem', color: '#c7d5e0' }}></span>
</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>
</>
);
}
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 title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
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="#5865f2"
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="#cb3837" />
<rect x="1" y="1" width="4" height="5" fill="white" />
<rect x="2" y="1" width="1" height="4" fill="#cb3837" />
<rect x="6" y="1" width="4" height="5" fill="white" />
<rect x="7" y="1" width="1" height="3" fill="#cb3837" />
<rect x="11" y="1" width="4" height="4" fill="white" />
<rect x="12" y="1" width="1" height="3" fill="#cb3837" />
</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="#f48024"
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 title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
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;
const thumbSrc = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
: null;
// If there's no image, fall back to a generic-style layout
if (!thumbSrc) {
return (
<>
<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;
return (
<>
{thumbUrl && (
<UrlPreviewImg
src={thumbUrl}
alt={prev['og:title']}
title={prev['og:title']}
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
onClick={onOpenViewer}
/>
)}
{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"
>
{!thumbUrl && (
<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(
useCallback(() => mx.getUrlPreview(url, ts), [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);
const wide = !!embed || isTwitterTweet(url);
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
// redirects) don't carry the id, so fall back to the canonical og:url that
// the homeserver already resolved when fetching the preview.
const ogUrl = prev['og:url'];
const resolvedEmbed =
embed ??
(typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null);
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 thumbUrl = mxcUrlToHttp(
mx,
prev['og:image'] || '',
useAuthentication,
256,
256,
'scale',
false,
);
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
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 content = renderContent(previewStatus.data);
if (content === null) return null;
return (
<UrlPreview {...props} ref={ref} className={cardClass}>
{content}
</UrlPreview>
);
}
return (
<UrlPreview {...props} ref={ref} className={cardClass}>
<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>
);
});