All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s
453 lines
14 KiB
TypeScript
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;
|