refactor(chat): tighten media cache boundaries
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user