Files
cinny/src/app/components/url-preview/UrlPreviewCard.tsx
T
jared 94fa0d96c2
CI / Build & Quality Checks (push) Successful in 10m21s
feat: GIF previews, room context menu, policy lists, mention pulse, collapsible messages, send animation, D&D fix
P3-5: Giphy/Tenor URL preview cards — full-width thumbnail from og:image
mxc URL, GIF badge overlay, site badge + title footer; GifCard shared by
both; BadgeGiphy (teal) and BadgeTenor (blue) CSS classes

P3-9: Policy list viewer — read-only panel in Room Settings + Space
Settings (admin/50+ PL only); enter room ID or alias; tabs for Users /
Rooms / Servers; glob pattern warning color; Ban badge; entity + reason

P5-8: Mention highlight pulse — 0.6s scale+glow keyframe on incoming
@mention messages; prefers-reduced-motion aware; only fires on new
incoming messages (isNewRef), not on history load; onAnimationEnd cleanup

P5-19: Collapsible long messages — ResizeObserver clamps text bodies
>320px with gradient fade + "Read more ↓" / "Show less ↑" button; resets
on eventId change; skips images/video/audio/file; smooth CSS transition

P5-23: Message send animation — own messages fade+scale in (0.97→1,
0.4→1 opacity, 150ms ease-out); prefers-reduced-motion aware; one-shot
via isNewRef + onAnimationEnd clear

P5-26: Room context menu — Copy Link (matrix.to URL, 1.5s Copied!
feedback), Mute with duration (15m/1h/8h/24h/indefinite, localStorage
timer key io.lotus.mute_timers), Mark as read; Icons.Link + Icons.BellMute

BUG D&D: dragCounter ref replaces fragile dragState machine — enter
increments, leave decrements (hides at 0), drop resets to 0; fixes
spurious dragleave from child element boundary crossings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:51:18 -04:00

1956 lines
59 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 { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import * as css from './UrlPreviewCard.css';
import * as previewCss from './UrlPreview.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
const linkStyles = { color: color.Success.Main };
// ---------------------------------------------------------------------------
// Helpers — URL parsing & variant detection
// ---------------------------------------------------------------------------
type CardVariant =
| 'youtube-shorts'
| 'youtube'
| 'tiktok'
| 'vimeo'
| 'github'
| 'twitter'
| 'reddit'
| 'spotify'
| 'twitch'
| 'steam'
| 'wikipedia'
| 'discord'
| 'npm'
| 'stackoverflow'
| 'imdb'
| 'giphy'
| 'tenor'
| 'generic';
function getYouTubeVideoId(url: string): string | null {
try {
const parsed = new URL(url);
const { hostname, pathname, searchParams } = parsed;
// youtu.be/<id>
if (hostname === 'youtu.be') {
const id = pathname.slice(1).split('/')[0];
return id || null;
}
// youtube.com/watch?v=<id>
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
if (pathname === '/watch') {
return searchParams.get('v');
}
// youtube.com/shorts/<id> — handled by isYouTubeShorts
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (shortsMatch) return shortsMatch[1];
}
} catch {
// ignore malformed URLs
}
return null;
}
function isYouTubeShorts(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
} catch {
return false;
}
}
function getYoutubeShortsId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
function isTikTok(url: string): boolean {
try {
const { hostname } = new URL(url);
const h = hostname.replace(/^www\./, '');
return h === 'tiktok.com' || h === 'vm.tiktok.com';
} catch {
return false;
}
}
function getTikTokUsername(url: string): string | null {
try {
const { pathname } = new URL(url);
const m = pathname.match(/\/((@[^/]+))/);
return m ? m[1] : null;
} catch {
return null;
}
}
function getVimeoVideoId(url: string): string | null {
try {
const parsed = new URL(url);
const { hostname, pathname } = parsed;
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
function isGitHubRepo(url: string): boolean {
try {
const parsed = new URL(url);
const { hostname, pathname } = parsed;
if (hostname !== 'github.com' && hostname !== 'www.github.com') return false;
// Exactly two path segments: /<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 {
// Shorts must be detected before generic youtube
if (isYouTubeShorts(url)) return 'youtube-shorts';
if (getYouTubeVideoId(url) !== null) return 'youtube';
if (isTikTok(url)) return 'tiktok';
if (getVimeoVideoId(url) !== null) return 'vimeo';
if (isGitHubRepo(url)) return 'github';
if (isTwitter(url)) return 'twitter';
if (getRedditSubreddit(url) !== null) return 'reddit';
if (getSpotifyType(url) !== null) return 'spotify';
if (isTwitch(url)) return 'twitch';
if (isSteamApp(url)) return 'steam';
if (isWikipedia(url)) return 'wikipedia';
if (isDiscordInvite(url)) return 'discord';
if (isNpm(url)) return 'npm';
if (isStackOverflow(url)) return 'stackoverflow';
if (isImdb(url)) return 'imdb';
if (isGiphy(url)) return 'giphy';
if (isTenor(url)) return 'tenor';
return 'generic';
}
function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
// ---------------------------------------------------------------------------
// Parsing helpers for individual cards
// ---------------------------------------------------------------------------
interface ParsedTweet {
name: string;
handle?: string;
text?: string;
}
function parseTweetTitle(ogTitle: string): ParsedTweet {
// Pattern 1: "Name on X: \"tweet text\"" or "Name on Twitter: \"tweet text\""
const onXMatch = ogTitle.match(/^(.+?) on (?:X|Twitter):\s*["""](.+)["""]?$/s);
if (onXMatch) return { name: onXMatch[1], text: onXMatch[2] };
// Pattern 2: "X / Name (@handle)" or "Twitter / Name (@handle)"
const xSlashMatch = ogTitle.match(/^(?:X|Twitter)\s*\/\s*(.+?)\s*\(@(.+?)\)/);
if (xSlashMatch) return { name: xSlashMatch[1], handle: xSlashMatch[2] };
// Pattern 3: "Name (@handle): text" or "Name (@handle)"
const handleMatch = ogTitle.match(/^(.+?)\s*\(@(.+?)\)(?::\s*(.+))?$/s);
if (handleMatch) return { name: handleMatch[1], handle: handleMatch[2], text: handleMatch[3] };
return { name: ogTitle };
}
function parseTikTokTitle(ogTitle: string): { username: string | null; caption: string | null } {
// Format 1: "@username on TikTok: "caption""
const fmt1 = ogTitle.match(/^(@\S+)\s+on\s+TikTok:\s*["""](.+)["""]?$/s);
if (fmt1) return { username: fmt1[1], caption: fmt1[2] };
// Format 2: "username TikTok | caption"
const fmt2 = ogTitle.match(/^(.+?)\s+TikTok\s*\|\s*(.+)$/s);
if (fmt2) return { username: fmt2[1], caption: fmt2[2] };
// Format 3: just caption
return { username: null, caption: ogTitle || null };
}
function extractHashtags(text: string): string[] {
const found: string[] = [];
const re = /#\w+/g;
let m = re.exec(text);
while (m !== null && found.length < 5) {
found.push(m[0]);
m = re.exec(text);
}
return found;
}
function parseRedditMeta(ogDescription: string): {
author: string | null;
upvotes: string | null;
comments: string | null;
} {
let author: string | null = null;
let upvotes: string | null = null;
let comments: string | null = null;
const authorM = ogDescription.match(/Posted\s+by\s+u\/(\S+)/i);
if (authorM) author = authorM[1];
const pointsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+(?:points?|upvotes?)/i);
if (pointsM) upvotes = pointsM[1];
const commentsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+comments?/i);
if (commentsM) comments = commentsM[1];
return { author, upvotes, comments };
}
function parseTwitchGame(ogDescription: string): string | null {
const m = ogDescription.match(/play(?:ing)?\s+(.+?)(?:\s*[-–—]|\s*\d+\s+viewer|$)/i);
return m ? m[1].trim() : null;
}
// ---------------------------------------------------------------------------
// Inline SVG logos
// ---------------------------------------------------------------------------
function GitHubIcon({ size = 24 }: { size?: number }) {
return (
<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>
);
}
// ---------------------------------------------------------------------------
// Shared VideoCard — used by YouTube (non-Shorts) and Vimeo
// ---------------------------------------------------------------------------
function VideoCard({
url,
prev,
thumbnailUrl,
siteBadgeLabel,
siteBadgeClass,
showPlayButton,
}: {
url: string;
prev: IPreviewUrlResponse;
thumbnailUrl: string;
siteBadgeLabel: string;
siteBadgeClass: string;
showPlayButton?: boolean;
}) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
return (
<>
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.MediaThumbnailWrapper}
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
>
<img
className={previewCss.MediaThumbnailImg}
src={thumbnailUrl}
alt={title}
loading="lazy"
/>
{showPlayButton !== false && (
<div className={previewCss.MediaPlayOverlay}>
<div className={previewCss.MediaPlayButton}>
<Icon size="400" src={Icons.Play} />
</div>
</div>
)}
</a>
<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>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// Card 1: YouTube Shorts
// ---------------------------------------------------------------------------
function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const videoId = getYoutubeShortsId(url) ?? '';
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
const title = prev['og:title'] ?? '';
// YouTube Shorts og:description is often the channel name
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
const channelName = rawDescription.length < 80 ? rawDescription : null;
return (
<>
{/* Header bar with Shorts badge */}
<div className={previewCss.ShortsHeader}>
<SiteBadge label="Shorts" colorClass={previewCss.BadgeYouTubeShorts} />
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
YouTube
</Text>
</div>
{/* Side-by-side layout */}
<div className={previewCss.PortraitSideLayout}>
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.PortraitThumbnail}
aria-label={`Watch Short: ${title}`}
>
<img
className={previewCss.PortraitThumbnailImg}
src={thumbnailSrc}
alt={title}
loading="lazy"
/>
<div className={previewCss.PortraitPlayOverlay}>
<div className={previewCss.PortraitPlayButton}></div>
</div>
</a>
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text
priority="400"
style={{
fontWeight: 700,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</Text>
)}
{channelName && (
<Text size="T200" priority="300" style={{ opacity: 0.65 }}>
{channelName}
</Text>
)}
<Text
size="T200"
priority="300"
style={{ opacity: 0.5, marginTop: 'auto' }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
youtube.com
</Text>
</Box>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Card 2: TikTok
// ---------------------------------------------------------------------------
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
// ---------------------------------------------------------------------------
function TwitterCard({
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 imageWidth = prev['og:image:width'] as number | undefined;
const isTweet = isTwitterTweet(url);
const { name, handle, text } = parseTweetTitle(rawTitle);
const tweetText = text ?? (isTweet ? rawDescription : undefined);
// Show media image if og:image is wide (>= 300px or unspecified with a tweet URL)
const showMedia = !!mxcImage && (imageWidth === undefined || imageWidth >= 300) && isTweet;
const mediaThumbSrc = showMedia
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
: null;
return (
<>
{/* Header row */}
<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>
<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"
/>
)}
<Text
size="T200"
priority="300"
style={{ opacity: 0.5 }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
{getDomain(url)}
</Text>
</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)
// ---------------------------------------------------------------------------
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const videoId = getYouTubeVideoId(url)!;
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
return (
<VideoCard
url={url}
prev={prev}
thumbnailUrl={thumbnailSrc}
siteBadgeLabel="YouTube"
siteBadgeClass=""
/>
);
}
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
// Vimeo always includes og:image for video thumbnails
const thumbnailSrc = (prev['og:image'] as string | undefined) ?? '';
return (
<VideoCard
url={url}
prev={prev}
thumbnailUrl={thumbnailSrc}
siteBadgeLabel="Vimeo"
siteBadgeClass={previewCss.BadgeVimeo}
/>
);
}
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 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' }}>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;
const domain = getDomain(url);
const faviconSrc = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=16`;
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 && (
<img
className={previewCss.GenericFaviconImg}
src={faviconSrc}
alt=""
aria-hidden="true"
loading="lazy"
style={{ marginRight: '4px', verticalAlign: 'text-bottom' }}
/>
)}
{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;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
const variant = getCardVariant(url);
switch (variant) {
case 'youtube-shorts':
return <YouTubeShortsCard url={url} prev={prev} />;
case 'youtube':
return <YouTubeCard url={url} prev={prev} />;
case 'tiktok':
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'vimeo':
return <VimeoCard url={url} prev={prev} />;
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}>
{content}
</UrlPreview>
);
}
return (
<UrlPreview {...props} ref={ref}>
<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>
);
});