a52c9e12a4
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>
2245 lines
71 KiB
TypeScript
2245 lines
71 KiB
TypeScript
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 }}>
|
||
Couldn’t load this TikTok — open it on TikTok.
|
||
</Text>
|
||
)}
|
||
{!(playing && videoId) && title && (
|
||
<Text truncate priority="400">
|
||
<b>{title}</b>
|
||
</Text>
|
||
)}
|
||
</UrlPreviewContent>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||
const title = prev['og:title'] ?? '';
|
||
const description = prev['og:description'] ?? '';
|
||
|
||
// GitHub og:title is usually "owner/repo: short description" — split on ': '
|
||
const colonIdx = title.indexOf(': ');
|
||
const repoName = colonIdx !== -1 ? title.slice(0, colonIdx) : title;
|
||
const repoDesc = colonIdx !== -1 ? title.slice(colonIdx + 2) : description;
|
||
|
||
return (
|
||
<>
|
||
<Box
|
||
shrink="No"
|
||
alignItems="Center"
|
||
justifyContent="Center"
|
||
className={previewCss.GitHubIconWrapper}
|
||
>
|
||
<GitHubIcon size={28} />
|
||
</Box>
|
||
<UrlPreviewContent>
|
||
<Text
|
||
style={linkStyles}
|
||
truncate
|
||
as="a"
|
||
href={url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
size="T200"
|
||
priority="300"
|
||
>
|
||
GitHub
|
||
</Text>
|
||
{repoName && (
|
||
<Text truncate priority="400">
|
||
<b>{repoName}</b>
|
||
</Text>
|
||
)}
|
||
{repoDesc && (
|
||
<Text size="T200" priority="300">
|
||
<UrlPreviewDescription>{repoDesc}</UrlPreviewDescription>
|
||
</Text>
|
||
)}
|
||
{description && description !== repoDesc && (
|
||
<Text size="T200" priority="300">
|
||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||
</Text>
|
||
)}
|
||
</UrlPreviewContent>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||
const 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>
|
||
);
|
||
});
|