feat(ui): blob-cached media URLs and proper download links

Add useMediaUrl hook with in-memory blob cache to prevent image
flickering on session switch. Use toDownloadUrl for all download links.
This commit is contained in:
viettranx committed 2026-03-25 18:26:14 +07:00
1 parent bb74120e36
commit 1355e4e0cb
5 files changed
+163 -39

No files matched your search

+15 -5
View File
@@ -2,7 +2,8 @@ import { useState, useCallback } from "react";
import { Download, FileText, FileCode, Music, Film, File } from "lucide-react";
import { ImageLightbox } from "@/components/shared/image-lightbox";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { formatSize } from "@/lib/file-helpers";
import { formatSize, toDownloadUrl } from "@/lib/file-helpers";
import { useMediaUrl } from "@/hooks/use-media-url";
import {
Dialog,
DialogContent,
@@ -34,6 +35,15 @@ function isMediaKind(kind: string): "image" | "audio" | "video" | null {
return null;
}
/** Image with blob-cached src to prevent flickering on session switch. */
function CachedImage({ src, alt, className, loading, onClick }: {
src: string; alt: string; className?: string; loading?: "lazy" | "eager";
onClick?: () => void;
}) {
const cachedSrc = useMediaUrl(src);
return <img src={cachedSrc} alt={alt} className={className} loading={loading} onClick={onClick} />;
}
export function MediaGallery({ items }: MediaGalleryProps) {
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
const [preview, setPreview] = useState<{
@@ -78,7 +88,7 @@ export function MediaGallery({ items }: MediaGalleryProps) {
onClick={() => setLightboxIdx(i)}
className="block w-full cursor-pointer"
>
<img
<CachedImage
src={item.path}
alt={item.fileName ?? ""}
className="h-40 w-full object-cover"
@@ -94,7 +104,7 @@ export function MediaGallery({ items }: MediaGalleryProps) {
)}
</div>
<a
href={item.path}
href={toDownloadUrl(item.path)}
download={item.fileName ?? "image"}
onClick={(e) => e.stopPropagation()}
className="shrink-0 rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer"
@@ -124,7 +134,7 @@ export function MediaGallery({ items }: MediaGalleryProps) {
)}
</button>
<a
href={item.path}
href={toDownloadUrl(item.path)}
download={item.fileName ?? "file"}
className="flex items-center px-2 py-1.5 text-muted-foreground hover:bg-muted cursor-pointer rounded-r-md border-l"
onClick={(e) => e.stopPropagation()}
@@ -158,7 +168,7 @@ export function MediaGallery({ items }: MediaGalleryProps) {
<DialogHeader className="flex-row items-center gap-2 pr-10">
<DialogTitle className="truncate text-base flex-1">{preview.name}</DialogTitle>
<a
href={preview.href}
href={toDownloadUrl(preview.href)}
download={preview.name}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
>
@@ -1,6 +1,7 @@
import { useEffect, useCallback } from "react";
import { X, Download } from "lucide-react";
import { formatSize } from "@/lib/file-helpers";
import { formatSize, toDownloadUrl } from "@/lib/file-helpers";
import { useMediaUrl } from "@/hooks/use-media-url";
interface ImageLightboxProps {
src: string;
@@ -24,6 +25,7 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb
}, [handleKeyDown]);
const displayName = fileName || alt || "image";
const cachedSrc = useMediaUrl(src);
return (
<div
@@ -32,7 +34,7 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb
>
<div className="absolute top-4 right-4 flex items-center gap-2">
<a
href={src}
href={toDownloadUrl(src)}
download={displayName}
onClick={(e) => e.stopPropagation()}
className="rounded-full bg-white/90 dark:bg-neutral-800/90 p-2.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer"
@@ -49,7 +51,7 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb
</button>
</div>
<img
src={src}
src={cachedSrc}
alt={alt ?? "image"}
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
@@ -8,7 +8,8 @@ import rehypeHighlight from "rehype-highlight";
const remarkPlugins = [remarkGfm];
const rehypePlugins = [rehypeHighlight];
import { useClipboard } from "@/hooks/use-clipboard";
import { toFileUrl } from "@/lib/file-helpers";
import { toFileUrl, toDownloadUrl } from "@/lib/file-helpers";
import { useMediaUrl } from "@/hooks/use-media-url";
import { Check, Copy, Download, FileText } from "lucide-react";
import { ImageLightbox } from "./image-lightbox";
import {
@@ -85,6 +86,43 @@ function fileNameFromHref(href: string): string {
return segments[segments.length - 1] ?? "file";
}
/** Extracted from useMemo components so useMediaUrl hook is valid. */
function CachedMarkdownImg({ src, alt, openLightbox, ...props }: {
src?: string; alt?: string;
openLightbox: (src: string, alt: string) => void;
[key: string]: unknown;
}) {
const resolvedSrc = isFileLink(src) ? toFileUrl(src!) : src;
const cachedSrc = useMediaUrl(resolvedSrc);
const displayName = alt || fileNameFromHref(src ?? "");
return (
<span className="group/img relative inline-block overflow-hidden rounded-lg border shadow-sm">
<img
src={cachedSrc}
alt={alt ?? "image"}
className="block max-w-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
}}
{...props}
/>
{resolvedSrc && (
<a
href={toDownloadUrl(resolvedSrc)}
download={displayName}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer"
title="Download"
>
<Download className="h-4.5 w-4.5" />
</a>
)}
</span>
);
}
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
const openLightbox = useCallback((src: string, alt: string) => setLightbox({ src, alt }), []);
@@ -154,7 +192,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
{children}
</button>
<a
href={resolvedHref}
href={toDownloadUrl(resolvedHref)}
download={name}
className="inline-flex items-center px-1 py-0.5 text-muted-foreground hover:bg-muted cursor-pointer rounded-r border-l"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
@@ -171,34 +209,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
);
},
img({ src, alt, ...props }: any) {
const resolvedSrc = isFileLink(src) ? toFileUrl(src!) : src;
const displayName = alt || fileNameFromHref(src ?? "");
return (
<span className="group/img relative inline-block overflow-hidden rounded-lg border shadow-sm">
<img
src={resolvedSrc}
alt={alt ?? "image"}
className="block max-w-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
}}
{...props}
/>
{resolvedSrc && (
<a
href={resolvedSrc}
download={displayName}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer"
title="Download"
>
<Download className="h-4.5 w-4.5" />
</a>
)}
</span>
);
return <CachedMarkdownImg src={src} alt={alt} openLightbox={openLightbox} {...props} />;
},
table({ children, ...props }) {
return (
@@ -262,7 +273,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
<DialogHeader className="flex-row items-center gap-2 pr-10">
<DialogTitle className="truncate text-base flex-1">{filePreview.name}</DialogTitle>
<a
href={filePreview.href}
href={toDownloadUrl(filePreview.href)}
download={filePreview.name}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
>
+27
View File
@@ -0,0 +1,27 @@
import { useState, useEffect } from "react";
import { getOrFetchUrl } from "@/lib/media-cache";
/** Returns a cached blob ObjectURL for the given signed media URL.
* On first render returns the signed URL as-is (no blank frame),
* then swaps to blob URL once fetched. On subsequent renders with
* the same clean path, returns the cached blob instantly. */
export function useMediaUrl(signedUrl: string | undefined): string | undefined {
const [url, setUrl] = useState(signedUrl);
useEffect(() => {
if (!signedUrl) {
setUrl(undefined);
return;
}
// Show signed URL immediately (browser may already have it cached)
setUrl(signedUrl);
let cancelled = false;
getOrFetchUrl(signedUrl).then((resolved) => {
if (!cancelled) setUrl(resolved);
});
return () => { cancelled = true; };
}, [signedUrl]);
return url;
}
+74
View File
@@ -0,0 +1,74 @@
/** Client-side blob cache for media URLs.
* Prevents image flickering when switching chat sessions —
* backend generates fresh ?ft= tokens on each chat.history call,
* so the same file gets a different URL each time.
* Cache key = clean path (without ?ft=), value = blob ObjectURL with 5-min TTL. */
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes, matches backend FileTokenTTL
interface CacheEntry {
objectUrl: string;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<string>>();
/** Strip ?ft=... or &ft=... from URL, return clean path. */
export function stripFt(url: string): string {
return url.replace(/[?&]ft=[^&\s)"'<>]*/g, "").replace(/[?&]$/, "");
}
/** Evict expired entries and revoke their ObjectURLs. */
function evictExpired() {
const now = Date.now();
for (const [key, entry] of cache) {
if (entry.expiresAt <= now) {
URL.revokeObjectURL(entry.objectUrl);
cache.delete(key);
}
}
}
/** Get cached ObjectURL or fetch blob and cache it.
* Returns the blob ObjectURL on success, or the original signed URL on failure. */
export async function getOrFetchUrl(signedUrl: string): Promise<string> {
evictExpired();
const key = stripFt(signedUrl);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.objectUrl;
}
// Deduplicate concurrent fetches for the same key
const existing = inflight.get(key);
if (existing) return existing;
const promise = (async () => {
try {
const res = await fetch(signedUrl);
if (!res.ok) return signedUrl;
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
cache.set(key, { objectUrl, expiresAt: Date.now() + CACHE_TTL_MS });
return objectUrl;
} catch {
return signedUrl; // graceful degradation
} finally {
inflight.delete(key);
}
})();
inflight.set(key, promise);
return promise;
}
/** Revoke all cached ObjectURLs (call on logout/disconnect). */
export function revokeAll() {
for (const entry of cache.values()) {
URL.revokeObjectURL(entry.objectUrl);
}
cache.clear();
inflight.clear();
}