This commit is contained in:
@@ -11,6 +11,8 @@ interface UrlPreview {
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
siteName?: string;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
}
|
||||
|
||||
const VIDEO_HOSTS = [
|
||||
@@ -51,6 +53,23 @@ function isVideoUrl(url: string): boolean {
|
||||
// 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;
|
||||
|
||||
@@ -65,9 +84,29 @@ function normaliseMetadata(raw: any): UrlPreview | null {
|
||||
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 };
|
||||
return { title, description, imageUrl, siteName, imageWidth, imageHeight };
|
||||
}
|
||||
|
||||
function useUrlPreview(url: string): UrlPreview | null {
|
||||
@@ -79,7 +118,22 @@ function useUrlPreview(url: string): UrlPreview | null {
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache.has(url)) {
|
||||
setPreview(previewCache.get(url) ?? null);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -112,6 +166,23 @@ function useUrlPreview(url: string): UrlPreview | 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);
|
||||
@@ -146,14 +217,34 @@ function DirectMediaEmbed({
|
||||
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}>
|
||||
<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;
|
||||
@@ -193,6 +284,15 @@ function DirectMediaEmbed({
|
||||
);
|
||||
}
|
||||
|
||||
// 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">
|
||||
@@ -200,7 +300,23 @@ function DirectMediaEmbed({
|
||||
className={styles.directImage}
|
||||
src={url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
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>
|
||||
@@ -263,33 +379,70 @@ function UrlPreviewEmbed({ url }: { url: string }) {
|
||||
<div className={styles.description}>{preview.description}</div>
|
||||
)}
|
||||
|
||||
{hasImage && (
|
||||
<div className={styles.mediaContainer}>
|
||||
<img
|
||||
className={styles.mediaImage}
|
||||
src={preview.imageUrl}
|
||||
alt={preview.title || ''}
|
||||
loading="lazy"
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user