refactor(chat): tighten media cache boundaries
This commit is contained in:
@@ -14,7 +14,12 @@ import { ChevronLeft } from "lucide-react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
import {
|
||||||
|
isBrowserLocalChatImageSource,
|
||||||
|
normalizeChatImageSource,
|
||||||
|
} from "@/lib/chat/chat_media_url";
|
||||||
|
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||||
|
|
||||||
import styles from "./fullscreen-image-viewer.module.css";
|
import styles from "./fullscreen-image-viewer.module.css";
|
||||||
|
|
||||||
export interface FullscreenImageViewerProps {
|
export interface FullscreenImageViewerProps {
|
||||||
@@ -46,13 +51,8 @@ export function FullscreenImageViewer({
|
|||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const sourceUrl = mediaUrl || imageUrl;
|
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
||||||
const src = sourceUrl.startsWith("data:") ||
|
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
||||||
sourceUrl.startsWith("http") ||
|
|
||||||
sourceUrl.startsWith("blob:")
|
|
||||||
? sourceUrl
|
|
||||||
: `data:image/png;base64,${sourceUrl}`;
|
|
||||||
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
|
|
||||||
|
|
||||||
if (imagePaywalled) {
|
if (imagePaywalled) {
|
||||||
return (
|
return (
|
||||||
@@ -131,7 +131,3 @@ export function FullscreenImageViewer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBrowserLocalImageSrc(src: string): boolean {
|
|
||||||
return src.startsWith("blob:") || src.startsWith("data:");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ROUTE_BUILDERS } from "@/router/routes";
|
import { ROUTE_BUILDERS } from "@/router/routes";
|
||||||
|
import {
|
||||||
|
isBrowserLocalChatImageSource,
|
||||||
|
normalizeChatImageSource,
|
||||||
|
} from "@/lib/chat/chat_media_url";
|
||||||
|
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||||
|
|
||||||
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
|
||||||
import styles from "./image-bubble.module.css";
|
import styles from "./image-bubble.module.css";
|
||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
@@ -36,9 +40,9 @@ export function ImageBubble({
|
|||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
|
|
||||||
const src = decodeBase64Image(mediaUrl || imageUrl);
|
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
||||||
const canOpen = Boolean(messageId);
|
const canOpen = Boolean(messageId);
|
||||||
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
|
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
||||||
|
|
||||||
const openImage = () => {
|
const openImage = () => {
|
||||||
if (!messageId) return;
|
if (!messageId) return;
|
||||||
@@ -85,17 +89,3 @@ export function ImageBubble({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析 base64 data URI(`data:image/png;base64,...`),否则原样返回 */
|
|
||||||
function decodeBase64Image(uri: string): string {
|
|
||||||
// 已是 data URI 或 http(s) URL:直接返回
|
|
||||||
if (uri.startsWith("data:") || uri.startsWith("http")) {
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
// 裸 base64 字符串 → 包装成 data URI(PNG 兜底)
|
|
||||||
return `data:image/png;base64,${uri}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBrowserLocalImageSrc(src: string): boolean {
|
|
||||||
return src.startsWith("blob:") || src.startsWith("data:");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { LockKeyhole, Pause, Play } from "lucide-react";
|
import { LockKeyhole, Pause, Play } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||||
import styles from "./voice-bubble.module.css";
|
import styles from "./voice-bubble.module.css";
|
||||||
|
|
||||||
export interface VoiceBubbleProps {
|
export interface VoiceBubbleProps {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export type ChatMediaKind = "image" | "audio";
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export * from "./chat_history_response";
|
export * from "./chat_history_response";
|
||||||
export * from "./chat_list_item";
|
export * from "./chat_list_item";
|
||||||
|
export * from "./chat_media";
|
||||||
export * from "./chat_message";
|
export * from "./chat_message";
|
||||||
export * from "./chat_send_response";
|
export * from "./chat_send_response";
|
||||||
export * from "./chat_sync_data";
|
export * from "./chat_sync_data";
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildChatMediaCacheKey,
|
||||||
|
fallbackChatMediaMimeType,
|
||||||
|
getMessageMediaTargets,
|
||||||
|
getSendResponseMediaTargets,
|
||||||
|
uniqueMediaTargets,
|
||||||
|
} from "../chat_media_cache_helpers";
|
||||||
|
|
||||||
|
function makeMessage(
|
||||||
|
overrides: Partial<Parameters<typeof ChatMessage.from>[0]> = {},
|
||||||
|
): ChatMessage {
|
||||||
|
return ChatMessage.from({
|
||||||
|
id: "msg-1",
|
||||||
|
role: "assistant",
|
||||||
|
type: "image",
|
||||||
|
content: "",
|
||||||
|
createdAt: "2026-06-30T00:00:00.000Z",
|
||||||
|
audioUrl: null,
|
||||||
|
image: { type: null, url: null },
|
||||||
|
lockDetail: {
|
||||||
|
locked: false,
|
||||||
|
showContent: true,
|
||||||
|
showUpgrade: false,
|
||||||
|
reason: null,
|
||||||
|
hint: null,
|
||||||
|
detail: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResponse(
|
||||||
|
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {},
|
||||||
|
): ChatSendResponse {
|
||||||
|
return ChatSendResponse.from({
|
||||||
|
reply: "",
|
||||||
|
audioUrl: "",
|
||||||
|
messageId: "msg-1",
|
||||||
|
isGuest: false,
|
||||||
|
timestamp: 1782356425363,
|
||||||
|
image: { type: null, url: null },
|
||||||
|
lockDetail: {
|
||||||
|
locked: false,
|
||||||
|
showContent: true,
|
||||||
|
showUpgrade: false,
|
||||||
|
reason: null,
|
||||||
|
hint: null,
|
||||||
|
detail: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("chat media cache helpers", () => {
|
||||||
|
it("builds owner-scoped media cache keys", () => {
|
||||||
|
expect(
|
||||||
|
buildChatMediaCacheKey({
|
||||||
|
ownerKey: "user:u1",
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "image",
|
||||||
|
}),
|
||||||
|
).toBe("user:u1:msg-1:image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts cacheable media targets from history messages", () => {
|
||||||
|
expect(
|
||||||
|
getMessageMediaTargets(
|
||||||
|
makeMessage({
|
||||||
|
audioUrl: "https://example.com/a.mp3",
|
||||||
|
image: { type: "jpg", url: "https://example.com/a.jpg" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://example.com/a.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://example.com/a.mp3",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty message ids and non-remote media urls", () => {
|
||||||
|
expect(
|
||||||
|
getSendResponseMediaTargets(
|
||||||
|
makeResponse({
|
||||||
|
messageId: "",
|
||||||
|
audioUrl: "https://example.com/a.mp3",
|
||||||
|
image: { type: "png", url: "data:image/png;base64,abc" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates media targets", () => {
|
||||||
|
expect(
|
||||||
|
uniqueMediaTargets([
|
||||||
|
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
||||||
|
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
||||||
|
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
||||||
|
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides fallback mime types", () => {
|
||||||
|
expect(fallbackChatMediaMimeType("image")).toBe("image/*");
|
||||||
|
expect(fallbackChatMediaMimeType("audio")).toBe("audio/mpeg");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type {
|
||||||
|
ChatMediaKind,
|
||||||
|
ChatMessage,
|
||||||
|
ChatSendResponse,
|
||||||
|
} from "@/data/dto/chat";
|
||||||
|
import type { CacheRemoteChatMediaInput } from "@/data/repositories/interfaces";
|
||||||
|
import { isCacheableRemoteChatMediaUrl } from "@/lib/chat/chat_media_url";
|
||||||
|
|
||||||
|
export { isCacheableRemoteChatMediaUrl };
|
||||||
|
|
||||||
|
export function buildChatMediaCacheKey(input: {
|
||||||
|
ownerKey: string;
|
||||||
|
messageId: string;
|
||||||
|
kind: ChatMediaKind;
|
||||||
|
}): string {
|
||||||
|
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageMediaTargets(
|
||||||
|
message: ChatMessage,
|
||||||
|
): CacheRemoteChatMediaInput[] {
|
||||||
|
const targets: CacheRemoteChatMediaInput[] = [];
|
||||||
|
pushMediaTarget(targets, {
|
||||||
|
messageId: message.id,
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: message.image.url,
|
||||||
|
});
|
||||||
|
pushMediaTarget(targets, {
|
||||||
|
messageId: message.id,
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: message.audioUrl,
|
||||||
|
});
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSendResponseMediaTargets(
|
||||||
|
response: ChatSendResponse,
|
||||||
|
): CacheRemoteChatMediaInput[] {
|
||||||
|
const targets: CacheRemoteChatMediaInput[] = [];
|
||||||
|
pushMediaTarget(targets, {
|
||||||
|
messageId: response.messageId,
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: response.image.url,
|
||||||
|
});
|
||||||
|
pushMediaTarget(targets, {
|
||||||
|
messageId: response.messageId,
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: response.audioUrl,
|
||||||
|
});
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fallbackChatMediaMimeType(kind: ChatMediaKind): string {
|
||||||
|
return kind === "image" ? "image/*" : "audio/mpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueMediaTargets(
|
||||||
|
targets: readonly CacheRemoteChatMediaInput[],
|
||||||
|
): CacheRemoteChatMediaInput[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return targets.filter((target) => {
|
||||||
|
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushMediaTarget(
|
||||||
|
targets: CacheRemoteChatMediaInput[],
|
||||||
|
input: {
|
||||||
|
messageId: string;
|
||||||
|
kind: ChatMediaKind;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
if (input.messageId.length === 0) return;
|
||||||
|
if (
|
||||||
|
!input.remoteUrl ||
|
||||||
|
!isCacheableRemoteChatMediaUrl(input.remoteUrl)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targets.push({
|
||||||
|
messageId: input.messageId,
|
||||||
|
kind: input.kind,
|
||||||
|
remoteUrl: input.remoteUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,10 +22,18 @@ import {
|
|||||||
LocalChatMediaStorage,
|
LocalChatMediaStorage,
|
||||||
LocalChatStorage,
|
LocalChatStorage,
|
||||||
LocalMessage,
|
LocalMessage,
|
||||||
type LocalChatMediaKind,
|
|
||||||
type LocalChatMediaRow,
|
type LocalChatMediaRow,
|
||||||
} from "@/data/storage/chat";
|
} from "@/data/storage/chat";
|
||||||
|
import {
|
||||||
|
buildChatMediaCacheKey,
|
||||||
|
fallbackChatMediaMimeType,
|
||||||
|
getMessageMediaTargets,
|
||||||
|
getSendResponseMediaTargets,
|
||||||
|
isCacheableRemoteChatMediaUrl,
|
||||||
|
uniqueMediaTargets,
|
||||||
|
} from "./chat_media_cache_helpers";
|
||||||
import { createLazySingleton } from "./lazy_singleton";
|
import { createLazySingleton } from "./lazy_singleton";
|
||||||
|
import { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage";
|
||||||
|
|
||||||
const log = new Logger("DataRepositoriesChatRepository");
|
const log = new Logger("DataRepositoriesChatRepository");
|
||||||
|
|
||||||
@@ -180,7 +188,7 @@ export class ChatRepository implements IChatRepository {
|
|||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
): Promise<Result<LocalChatMediaRow>> {
|
): Promise<Result<LocalChatMediaRow>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
if (!isCacheableRemoteUrl(input.remoteUrl)) {
|
if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) {
|
||||||
throw new Error(`Unsupported media url: ${input.remoteUrl}`);
|
throw new Error(`Unsupported media url: ${input.remoteUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,10 +225,12 @@ export class ChatRepository implements IChatRepository {
|
|||||||
remoteUrl: input.remoteUrl,
|
remoteUrl: input.remoteUrl,
|
||||||
blob,
|
blob,
|
||||||
mimeType:
|
mimeType:
|
||||||
blob.type || response.headers.get("content-type") || fallbackMimeType(input.kind),
|
blob.type ||
|
||||||
|
response.headers.get("content-type") ||
|
||||||
|
fallbackChatMediaMimeType(input.kind),
|
||||||
});
|
});
|
||||||
if (Result.isErr(saveResult)) throw saveResult.error;
|
if (Result.isErr(saveResult)) throw saveResult.error;
|
||||||
void requestPersistentStorage();
|
void requestBrowserPersistentStorageOnce();
|
||||||
|
|
||||||
const savedResult = await this.mediaStorage.getMedia(cacheKey);
|
const savedResult = await this.mediaStorage.getMedia(cacheKey);
|
||||||
if (Result.isErr(savedResult)) throw savedResult.error;
|
if (Result.isErr(savedResult)) throw savedResult.error;
|
||||||
@@ -333,7 +343,11 @@ export class ChatRepository implements IChatRepository {
|
|||||||
ownerKey: string,
|
ownerKey: string,
|
||||||
input: ChatMediaLookupInput,
|
input: ChatMediaLookupInput,
|
||||||
): string {
|
): string {
|
||||||
return `${ownerKey}:${input.messageId}:${input.kind}`;
|
return buildChatMediaCacheKey({
|
||||||
|
ownerKey,
|
||||||
|
messageId: input.messageId,
|
||||||
|
kind: input.kind,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,90 +360,3 @@ export const getChatRepository = createLazySingleton<IChatRepository>(
|
|||||||
LocalChatMediaStorage.getInstance(),
|
LocalChatMediaStorage.getInstance(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
function getMessageMediaTargets(
|
|
||||||
message: ChatMessage,
|
|
||||||
): CacheRemoteChatMediaInput[] {
|
|
||||||
const targets: CacheRemoteChatMediaInput[] = [];
|
|
||||||
pushMediaTarget(targets, {
|
|
||||||
messageId: message.id,
|
|
||||||
kind: "image",
|
|
||||||
remoteUrl: message.image.url,
|
|
||||||
});
|
|
||||||
pushMediaTarget(targets, {
|
|
||||||
messageId: message.id,
|
|
||||||
kind: "audio",
|
|
||||||
remoteUrl: message.audioUrl,
|
|
||||||
});
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSendResponseMediaTargets(
|
|
||||||
response: ChatSendResponse,
|
|
||||||
): CacheRemoteChatMediaInput[] {
|
|
||||||
const targets: CacheRemoteChatMediaInput[] = [];
|
|
||||||
pushMediaTarget(targets, {
|
|
||||||
messageId: response.messageId,
|
|
||||||
kind: "image",
|
|
||||||
remoteUrl: response.image.url,
|
|
||||||
});
|
|
||||||
pushMediaTarget(targets, {
|
|
||||||
messageId: response.messageId,
|
|
||||||
kind: "audio",
|
|
||||||
remoteUrl: response.audioUrl,
|
|
||||||
});
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushMediaTarget(
|
|
||||||
targets: CacheRemoteChatMediaInput[],
|
|
||||||
input: {
|
|
||||||
messageId: string;
|
|
||||||
kind: LocalChatMediaKind;
|
|
||||||
remoteUrl: string | null;
|
|
||||||
},
|
|
||||||
): void {
|
|
||||||
if (input.messageId.length === 0) return;
|
|
||||||
if (!input.remoteUrl || !isCacheableRemoteUrl(input.remoteUrl)) return;
|
|
||||||
targets.push({
|
|
||||||
messageId: input.messageId,
|
|
||||||
kind: input.kind,
|
|
||||||
remoteUrl: input.remoteUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCacheableRemoteUrl(url: string): boolean {
|
|
||||||
return url.startsWith("http://") || url.startsWith("https://");
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackMimeType(kind: LocalChatMediaKind): string {
|
|
||||||
return kind === "image" ? "image/*" : "audio/mpeg";
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueMediaTargets(
|
|
||||||
targets: readonly CacheRemoteChatMediaInput[],
|
|
||||||
): CacheRemoteChatMediaInput[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return targets.filter((target) => {
|
|
||||||
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let storagePersistenceRequested = false;
|
|
||||||
|
|
||||||
async function requestPersistentStorage(): 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("[chat-media] persistent storage requested", { persisted });
|
|
||||||
} catch (error) {
|
|
||||||
log.debug("[chat-media] persistent storage request skipped", { error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,19 +14,17 @@
|
|||||||
import type { Result } from "@/utils";
|
import type { Result } from "@/utils";
|
||||||
import type {
|
import type {
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
|
ChatMediaKind,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
UnlockHistoryResponse,
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import type {
|
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||||
LocalChatMediaKind,
|
|
||||||
LocalChatMediaRow,
|
|
||||||
} from "@/data/storage/chat";
|
|
||||||
|
|
||||||
export interface ChatMediaLookupInput {
|
export interface ChatMediaLookupInput {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
kind: LocalChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
|
export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Dexie, { type Table } from "dexie";
|
import Dexie, { type Table } from "dexie";
|
||||||
|
import type { ChatMediaKind } from "@/data/dto/chat";
|
||||||
import type {
|
import type {
|
||||||
ChatImageData,
|
ChatImageData,
|
||||||
ChatLockDetailData,
|
ChatLockDetailData,
|
||||||
@@ -32,13 +33,11 @@ export interface LocalMessageRow {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalChatMediaKind = "image" | "audio";
|
|
||||||
|
|
||||||
export interface LocalChatMediaRow {
|
export interface LocalChatMediaRow {
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
ownerKey: string;
|
ownerKey: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
kind: LocalChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Result, type Result as ResultT } from "@/utils";
|
import { Result, type Result as ResultT } from "@/utils";
|
||||||
|
import type { ChatMediaKind } from "@/data/dto/chat";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LocalChatDB,
|
LocalChatDB,
|
||||||
type LocalChatMediaKind,
|
|
||||||
type LocalChatMediaRow,
|
type LocalChatMediaRow,
|
||||||
} from "./local_chat_db";
|
} from "./local_chat_db";
|
||||||
|
|
||||||
export type { LocalChatMediaKind, LocalChatMediaRow };
|
export type { LocalChatMediaRow };
|
||||||
|
|
||||||
export interface SaveLocalChatMediaInput {
|
export interface SaveLocalChatMediaInput {
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
ownerKey: string;
|
ownerKey: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
kind: LocalChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
|||||||
@@ -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:");
|
||||||
|
}
|
||||||
+20
-13
@@ -2,14 +2,19 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { LocalChatMediaKind } from "@/data/storage/chat";
|
import type { ChatMediaKind } from "@/data/dto/chat";
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
|
||||||
import { Result } from "@/utils";
|
import { Result } from "@/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
cacheRemoteChatMediaBlob,
|
||||||
|
resolveCachedChatMediaBlob,
|
||||||
|
} from "./chat_media_cache_client";
|
||||||
|
import { isCacheableRemoteChatMediaUrl } from "./chat_media_url";
|
||||||
|
|
||||||
export interface UseCachedChatMediaUrlInput {
|
export interface UseCachedChatMediaUrlInput {
|
||||||
messageId?: string | null;
|
messageId?: string | null;
|
||||||
remoteUrl?: string | null;
|
remoteUrl?: string | null;
|
||||||
kind: LocalChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCachedChatMediaUrlOutput {
|
export interface UseCachedChatMediaUrlOutput {
|
||||||
@@ -35,7 +40,11 @@ export function useCachedChatMediaUrl({
|
|||||||
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
|
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!messageId || !remoteUrl || !isCacheableRemoteUrl(remoteUrl)) {
|
if (
|
||||||
|
!messageId ||
|
||||||
|
!remoteUrl ||
|
||||||
|
!isCacheableRemoteChatMediaUrl(remoteUrl)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,16 +68,18 @@ export function useCachedChatMediaUrl({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveCachedMedia = async () => {
|
const resolveCachedMedia = async () => {
|
||||||
const chatRepo = getChatRepository();
|
const cachedResult = await resolveCachedChatMediaBlob({
|
||||||
const cachedResult = await chatRepo.getCachedMedia({ messageId, kind });
|
messageId,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (Result.isOk(cachedResult) && cachedResult.data) {
|
if (Result.isOk(cachedResult) && cachedResult.data) {
|
||||||
applyBlob(cachedResult.data.blob);
|
applyBlob(cachedResult.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheResult = await chatRepo.cacheRemoteMedia({
|
const cacheResult = await cacheRemoteChatMediaBlob({
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
@@ -76,7 +87,7 @@ export function useCachedChatMediaUrl({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (Result.isOk(cacheResult)) {
|
if (Result.isOk(cacheResult)) {
|
||||||
applyBlob(cacheResult.data.blob);
|
applyBlob(cacheResult.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,7 +111,3 @@ export function useCachedChatMediaUrl({
|
|||||||
isUsingCachedMedia: false,
|
isUsingCachedMedia: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCacheableRemoteUrl(url: string): boolean {
|
|
||||||
return url.startsWith("http://") || url.startsWith("https://");
|
|
||||||
}
|
|
||||||
@@ -67,6 +67,7 @@ export const loadMoreHistoryActor = fromPromise<
|
|||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
const page = localMessagesToUi(result.data.messages);
|
const page = localMessagesToUi(result.data.messages);
|
||||||
|
void chatRepo.prefetchMediaForMessages(result.data.messages);
|
||||||
return {
|
return {
|
||||||
messages: page,
|
messages: page,
|
||||||
hasMore: page.length >= PAGE_SIZE,
|
hasMore: page.length >= PAGE_SIZE,
|
||||||
|
|||||||
Reference in New Issue
Block a user