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
@@ -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:");
}
+7 -17
View File
@@ -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 URIPNG 兜底)
return `data:image/png;base64,${uri}`;
}
function isBrowserLocalImageSrc(src: string): boolean {
return src.startsWith("blob:") || src.startsWith("data:");
}
+1 -1
View File
@@ -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 {
+1
View File
@@ -0,0 +1 @@
export type ChatMediaKind = "image" | "audio";
+1
View File
@@ -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,
});
}
+19 -92
View File
@@ -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 {
+2 -3
View File
@@ -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;
+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:");
}
@@ -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://");
}
+1
View File
@@ -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,