refactor(chat): tighten media cache boundaries

This commit is contained in:
2026-06-30 16:12:02 +08:00
parent ccd2d6bd48
commit 9cff16e2d9
17 changed files with 388 additions and 146 deletions
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { Logger } from "@/utils";
const log = new Logger("LibBrowserPersistentStorage");
let storagePersistenceRequested = false;
export async function requestBrowserPersistentStorageOnce(): Promise<void> {
if (storagePersistenceRequested) return;
storagePersistenceRequested = true;
if (typeof navigator === "undefined") return;
if (!navigator.storage?.persist) return;
try {
const persisted = await navigator.storage.persist();
log.debug("[persistent-storage] requested", { persisted });
} catch (error) {
log.debug("[persistent-storage] request skipped", { error });
}
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
isBrowserLocalChatImageSource,
isCacheableRemoteChatMediaUrl,
normalizeChatImageSource,
} from "../chat_media_url";
describe("chat media url helpers", () => {
it("detects cacheable remote media urls", () => {
expect(isCacheableRemoteChatMediaUrl("https://example.com/a.jpg")).toBe(
true,
);
expect(isCacheableRemoteChatMediaUrl("http://example.com/a.jpg")).toBe(
true,
);
expect(isCacheableRemoteChatMediaUrl("blob:http://app/media")).toBe(false);
expect(isCacheableRemoteChatMediaUrl("data:image/png;base64,abc")).toBe(
false,
);
});
it("normalizes bare base64 image payloads", () => {
expect(normalizeChatImageSource("abc123")).toBe(
"data:image/png;base64,abc123",
);
expect(normalizeChatImageSource("blob:http://app/media")).toBe(
"blob:http://app/media",
);
expect(normalizeChatImageSource("https://example.com/a.jpg")).toBe(
"https://example.com/a.jpg",
);
});
it("detects image sources that should bypass Next image optimization", () => {
expect(isBrowserLocalChatImageSource("blob:http://app/media")).toBe(true);
expect(isBrowserLocalChatImageSource("data:image/png;base64,abc")).toBe(
true,
);
expect(isBrowserLocalChatImageSource("https://example.com/a.jpg")).toBe(
false,
);
});
});
+31
View File
@@ -0,0 +1,31 @@
"use client";
import type { ChatMediaKind } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Result, type Result as ResultT } from "@/utils";
export interface ResolveCachedChatMediaBlobInput {
messageId: string;
kind: ChatMediaKind;
}
export interface CacheRemoteChatMediaBlobInput
extends ResolveCachedChatMediaBlobInput {
remoteUrl: string;
}
export async function resolveCachedChatMediaBlob(
input: ResolveCachedChatMediaBlobInput,
): Promise<ResultT<Blob | null>> {
const result = await getChatRepository().getCachedMedia(input);
if (Result.isErr(result)) return result;
return Result.ok(result.data?.blob ?? null);
}
export async function cacheRemoteChatMediaBlob(
input: CacheRemoteChatMediaBlobInput,
): Promise<ResultT<Blob>> {
const result = await getChatRepository().cacheRemoteMedia(input);
if (Result.isErr(result)) return result;
return Result.ok(result.data.blob);
}
+18
View File
@@ -0,0 +1,18 @@
export function isCacheableRemoteChatMediaUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
export function normalizeChatImageSource(url: string): string {
if (
url.startsWith("data:") ||
url.startsWith("http") ||
url.startsWith("blob:")
) {
return url;
}
return `data:image/png;base64,${url}`;
}
export function isBrowserLocalChatImageSource(src: string): boolean {
return src.startsWith("blob:") || src.startsWith("data:");
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import type { ChatMediaKind } from "@/data/dto/chat";
import { Result } from "@/utils";
import {
cacheRemoteChatMediaBlob,
resolveCachedChatMediaBlob,
} from "./chat_media_cache_client";
import { isCacheableRemoteChatMediaUrl } from "./chat_media_url";
export interface UseCachedChatMediaUrlInput {
messageId?: string | null;
remoteUrl?: string | null;
kind: ChatMediaKind;
}
export interface UseCachedChatMediaUrlOutput {
mediaUrl: string;
isUsingCachedMedia: boolean;
}
interface CachedMediaUrlState {
key: string;
objectUrl: string;
}
export function useCachedChatMediaUrl({
messageId,
remoteUrl,
kind,
}: UseCachedChatMediaUrlInput): UseCachedChatMediaUrlOutput {
const [cachedState, setCachedState] = useState<CachedMediaUrlState | null>(
null,
);
const fallbackUrl = remoteUrl ?? "";
const stateKey =
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
useEffect(() => {
if (
!messageId ||
!remoteUrl ||
!isCacheableRemoteChatMediaUrl(remoteUrl)
) {
return;
}
let cancelled = false;
let objectUrl: string | null = null;
const applyBlob = (blob: Blob) => {
const nextObjectUrl = URL.createObjectURL(blob);
objectUrl = nextObjectUrl;
if (cancelled) {
URL.revokeObjectURL(nextObjectUrl);
return;
}
setCachedState((previous) => {
if (previous) URL.revokeObjectURL(previous.objectUrl);
return {
key: stateKey,
objectUrl: nextObjectUrl,
};
});
};
const resolveCachedMedia = async () => {
const cachedResult = await resolveCachedChatMediaBlob({
messageId,
kind,
});
if (cancelled) return;
if (Result.isOk(cachedResult) && cachedResult.data) {
applyBlob(cachedResult.data);
return;
}
const cacheResult = await cacheRemoteChatMediaBlob({
messageId,
kind,
remoteUrl,
});
if (cancelled) return;
if (Result.isOk(cacheResult)) {
applyBlob(cacheResult.data);
}
};
void resolveCachedMedia();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [kind, messageId, remoteUrl, stateKey]);
if (cachedState?.key === stateKey) {
return {
mediaUrl: cachedState.objectUrl,
isUsingCachedMedia: true,
};
}
return {
mediaUrl: fallbackUrl,
isUsingCachedMedia: false,
};
}