Files
DiscordClone/packages/shared/src/components/channel/LinkEmbed.tsx
Bryan1029384756 6813bb40dc
All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s
1.1.00
2026-04-16 20:14:27 -05:00

453 lines
14 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { ArrowSquareOut, Play } from '@phosphor-icons/react';
import { useAction } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { PausedGif } from './PausedGif';
import styles from './LinkEmbed.module.css';
interface UrlPreview {
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
imageWidth?: number;
imageHeight?: number;
}
const VIDEO_HOSTS = [
'youtube.com',
'youtu.be',
'www.youtube.com',
'vimeo.com',
'www.vimeo.com',
'twitch.tv',
'www.twitch.tv',
'dailymotion.com',
'www.dailymotion.com',
];
const DIRECT_VIDEO_EXTS = ['.mp4', '.webm', '.ogg', '.mov'];
const DIRECT_IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
function isDirectMedia(url: string): 'video' | 'image' | null {
try {
const pathname = new URL(url).pathname.toLowerCase();
if (DIRECT_VIDEO_EXTS.some((ext) => pathname.endsWith(ext))) return 'video';
if (DIRECT_IMAGE_EXTS.some((ext) => pathname.endsWith(ext))) return 'image';
} catch {}
return null;
}
function isVideoUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname;
return VIDEO_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h));
} catch {
return false;
}
}
// Module-scope cache so we don't refetch the same URL every re-render.
// `null` means "we tried and there's no preview" — we cache that too to
// avoid hammering the fetcher for URLs that will never resolve.
const previewCache = new Map<string, UrlPreview | null>();
// Natural dimensions learned on first successful <img> onLoad for every
// embed image (OG preview image, direct image URL). Populates the
// reserved-box aspect-ratio so the next mount of the same URL goes
// straight to its real proportions instead of the fixed fallback.
const embedImageDimsCache = new Map<string, { w: number; h: number }>();
// Fallback box for an embed image whose dimensions we haven't probed
// yet. Roughly matches the common OG-image aspect ratio (~1.91:1 for
// Twitter/Facebook card images). Fixed-size fallback > fluid fallback
// because a wrong fluid ratio shifts height when the real image lands.
const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const;
// Direct inline video embeds default to 16:9 — most web video ships at
// that ratio. A wrong default just means a little blank space above or
// below the video, not a scroll jump.
const DIRECT_VIDEO_FALLBACK_RATIO = '16 / 9';
function normaliseMetadata(raw: any): UrlPreview | null {
if (!raw || typeof raw !== 'object') return null;
// Accept a few possible shapes: the Electron preload IPC returns
// `{ title, description, image, siteName, url }`; matrix-js-sdk style
// returns `{ 'og:title', 'og:description', 'og:image', 'og:site_name' }`.
const title =
raw.title ?? raw['og:title'] ?? raw.ogTitle ?? undefined;
const description =
raw.description ?? raw['og:description'] ?? raw.ogDescription ?? undefined;
const imageUrl =
raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined;
const siteName =
raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined;
// Dimensions come from the Convex action's `imageWidth`/`imageHeight`
// fields (parsed from og:image:width / og:image:height on the server).
// Fall through a few other naming conventions in case a platform-
// native fetcher emits the OG keys verbatim.
const pickNum = (v: unknown): number | undefined => {
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n) && n > 0) return n;
}
return undefined;
};
const imageWidth =
pickNum(raw.imageWidth) ??
pickNum(raw['og:image:width']) ??
pickNum(raw.ogImageWidth);
const imageHeight =
pickNum(raw.imageHeight) ??
pickNum(raw['og:image:height']) ??
pickNum(raw.ogImageHeight);
if (!title && !description && !imageUrl) return null;
return { title, description, imageUrl, siteName, imageWidth, imageHeight };
}
function useUrlPreview(url: string): UrlPreview | null {
const platform = usePlatform();
const fetchPreviewAction = useAction(api.links.fetchPreview);
const [preview, setPreview] = useState<UrlPreview | null>(
previewCache.get(url) ?? null,
);
useEffect(() => {
if (previewCache.has(url)) {
const cached = previewCache.get(url) ?? null;
// Same cache-seeding as the fresh-fetch branch below — ensures
// the reserved image box is correct even when the preview came
// out of the module cache on a re-render.
if (
cached?.imageUrl &&
cached.imageWidth &&
cached.imageHeight &&
!embedImageDimsCache.has(cached.imageUrl)
) {
embedImageDimsCache.set(cached.imageUrl, {
w: cached.imageWidth,
h: cached.imageHeight,
});
}
setPreview(cached);
return;
}
let cancelled = false;
(async () => {
try {
// Prefer the platform-native fetcher when available (Electron
// ships one via IPC — no CORS, no round-trip through the
// backend). On web, `fetchMetadata` returns null due to CORS,
// so we fall through to the Convex Node action which performs
// the fetch server-side.
let result: UrlPreview | null = null;
const fetcher = platform?.links?.fetchMetadata;
if (typeof fetcher === 'function') {
try {
const raw = await fetcher(url);
result = normaliseMetadata(raw);
} catch {
result = null;
}
}
if (!result) {
try {
const raw = await fetchPreviewAction({ url });
if (!cancelled) result = normaliseMetadata(raw);
} catch {
result = null;
}
}
if (cancelled) return;
previewCache.set(url, result);
// Server-provided image dimensions populate the same
// cache the <img> onLoad handler updates — so the
// reserved box is correct on the very first paint of
// the preview card, not only after the image finishes
// decoding. Falls through to the onLoad probe if the
// server didn't have width/height tags.
if (
result?.imageUrl &&
result.imageWidth &&
result.imageHeight &&
!embedImageDimsCache.has(result.imageUrl)
) {
embedImageDimsCache.set(result.imageUrl, {
w: result.imageWidth,
h: result.imageHeight,
});
}
setPreview(result);
} catch {
if (!cancelled) previewCache.set(url, null);
}
})();
return () => {
cancelled = true;
};
}, [url, platform, fetchPreviewAction]);
return preview;
}
function DirectMediaEmbed({
url,
type,
onOpenGif,
}: {
url: string;
type: 'video' | 'image';
onOpenGif?: (url: string) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
if (type === 'video') {
const handlePlay = () => {
if (!videoRef.current) return;
videoRef.current.controls = true;
void videoRef.current.play();
setPlaying(true);
};
// `preload="metadata"` leaves the <video> element at zero height
// until `loadedmetadata` fires — that was a measurable source of
// scroll jump. Wrap it in an aspect-ratio box so the space is
// reserved from the first paint. 16:9 is the overwhelming majority
// of web video; when the real metadata lands and differs slightly
// the ResizeObserver catches it, but the gross box is already
// there.
return (
<div className={`${styles.embed} ${styles.embedBare}`}>
<div
className={styles.directVideoWrapper}
style={{
aspectRatio: DIRECT_VIDEO_FALLBACK_RATIO,
width: 400,
maxWidth: '100%',
}}
>
<video
ref={videoRef}
className={styles.directVideo}
src={url}
preload="metadata"
style={{ width: '100%', height: '100%' }}
onLoadedMetadata={() => {
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);
}}
onPause={() => {
if (videoRef.current && videoRef.current.ended) {
videoRef.current.controls = false;
setPlaying(false);
}
}}
onEnded={() => {
if (videoRef.current) {
videoRef.current.controls = false;
setPlaying(false);
}
}}
/>
{!playing && (
<button
type="button"
className={styles.directPlayOverlay}
onClick={handlePlay}
>
<Play size={36} weight="fill" />
</button>
)}
</div>
</div>
);
}
// GIFs (and other direct images) render through PausedGif so
// the idle state freezes on the first frame and only animates
// on hover. Click opens a fullscreen viewer via `onOpenGif` —
// the parent MessageGroup owns the lightbox state and reuses
// its existing ImageLightbox.
const isGif = /\.gif(\?|#|$)/i.test(url);
if (isGif) {
return (
<PausedGif url={url} onOpen={onOpenGif} className={styles.directGif} />
);
}
// Direct image embed: reserve a box from the probed cache (or a
// fixed fallback) so the image lands in a slot of known height
// instead of expanding the wrapper from zero. `loading="lazy"` was
// here but removed — a direct-image embed is always rendered in
// view when it first mounts, and the deferred decode defeats the
// scroll anchor window we're trying to hold onto.
const probed = embedImageDimsCache.get(url) ?? EMBED_IMG_FALLBACK;
const boxW = Math.min(probed.w, 400);
const boxH = Math.round(boxW * (probed.h / probed.w));
return (
<div className={`${styles.embed} ${styles.embedBare}`}>
<a href={url} target="_blank" rel="noopener noreferrer">
<img
className={styles.directImage}
src={url}
alt=""
decoding="async"
width={boxW}
height={boxH}
style={{
aspectRatio: `${probed.w} / ${probed.h}`,
}}
onLoad={(e) => {
const img = e.currentTarget;
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w > 0 && h > 0) {
embedImageDimsCache.set(url, { w, h });
}
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);
}}
/>
</a>
</div>
);
}
interface LinkEmbedProps {
url: string;
/** Called when the user clicks a paused GIF preview. The parent
* owns the fullscreen viewer state (MessageGroup reuses its
* existing ImageLightbox). Ignored for non-GIF embeds. */
onOpenGif?: (url: string) => void;
}
export function LinkEmbed({ url, onOpenGif }: LinkEmbedProps) {
// Thin router: direct media gets its own component, rich previews
// get theirs. Keeping the two paths in separate component instances
// means `useUrlPreview` (which internally wraps `useAction` and
// therefore `useMemo`) is never called conditionally — otherwise a
// URL change that flipped `isDirectMedia` would mutate the hook
// count and trigger React error #310.
const directType = isDirectMedia(url);
if (directType) {
return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
}
return <UrlPreviewEmbed url={url} />;
}
function UrlPreviewEmbed({ url }: { url: string }) {
const preview = useUrlPreview(url);
// If the platform has no metadata fetcher (or it returned null / threw),
// degrade gracefully to nothing — the raw link is already rendered in
// the message body by MessageContent.
if (!preview) return null;
const isVideo = isVideoUrl(url);
const hasImage = !!preview.imageUrl;
return (
<div className={styles.embed}>
<div className={styles.grid}>
<div className={styles.embedContent}>
{preview.siteName && (
<div className={styles.provider}>{preview.siteName}</div>
)}
{preview.title && (
<a
className={styles.title}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{preview.title}
</a>
)}
{preview.description && (
<div className={styles.description}>{preview.description}</div>
)}
{hasImage && (() => {
// Reserve a box for the OG image so the card's
// final height is known before the image loads.
// Without this the card appears title-first, then
// expands downward as the image decodes — the
// classic link-preview height shift.
const imgUrl = preview.imageUrl!;
const probed =
embedImageDimsCache.get(imgUrl) ?? EMBED_IMG_FALLBACK;
return (
<div
className={styles.mediaContainer}
style={{
aspectRatio: `${probed.w} / ${probed.h}`,
// max-height matches .mediaImage CSS so
// the container never exceeds the
// image's own cap and the reserved
// space matches the rendered space.
maxHeight: 300,
width: '100%',
}}
>
<img
className={styles.mediaImage}
src={imgUrl}
alt={preview.title || ''}
decoding="async"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
onLoad={(e) => {
const img = e.currentTarget;
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w > 0 && h > 0) {
embedImageDimsCache.set(imgUrl, { w, h });
}
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);
}}
/>
{isVideo && (
<a
className={styles.mediaOverlay}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<div className={styles.mediaControls}>
<button type="button" className={styles.playButton}>
<Play size={28} weight="fill" />
</button>
<button type="button" className={styles.openButton}>
<ArrowSquareOut size={22} />
</button>
</div>
</a>
)}
</div>
);
})()}
</div>
</div>
</div>
);
}
export default LinkEmbed;