1.1.00
All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s

This commit is contained in:
Bryan1029384756
2026-04-16 20:14:27 -05:00
parent 56a12fdf3e
commit 6813bb40dc
40 changed files with 2228 additions and 387 deletions

View File

@@ -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>