fix(chat): make cached media load on safari

This commit is contained in:
2026-07-01 20:13:56 +08:00
parent 8586e87ab6
commit c20769b6a3
11 changed files with 299 additions and 72 deletions
@@ -12,7 +12,7 @@
*/
import { ChevronLeft } from "lucide-react";
import Image from "next/image";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import {
isBrowserLocalChatImageSource,
@@ -39,11 +39,13 @@ export function FullscreenImageViewer({
onUnlockImagePaywall,
onClose,
}: FullscreenImageViewerProps) {
const { mediaUrl } = useCachedChatMediaUrl({
messageId,
remoteUrl: imageUrl,
kind: "image",
});
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
messageId,
remoteUrl: imageUrl,
kind: "image",
});
const [imageErrorSrc, setImageErrorSrc] = useState<string | null>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -55,6 +57,15 @@ export function FullscreenImageViewer({
const src = normalizeChatImageSource(mediaUrl || imageUrl);
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
const hasImageError = imageErrorSrc === src;
const handleImageError = () => {
if (isUsingCachedMedia) {
reportMediaError();
return;
}
setImageErrorSrc(src);
};
if (imagePaywalled) {
return (
@@ -68,6 +79,7 @@ export function FullscreenImageViewer({
src={src}
alt=""
className={`${styles.nativePaywallImage} ${styles.paywallImage}`}
onError={handleImageError}
/>
) : (
<Image
@@ -76,6 +88,7 @@ export function FullscreenImageViewer({
fill
sizes="(max-width: 540px) 100vw, 540px"
className={styles.paywallImage}
onError={handleImageError}
/>
)}
<div className={styles.paywallScrim} aria-hidden="true" />
@@ -108,16 +121,14 @@ export function FullscreenImageViewer({
role="dialog"
aria-label="Fullscreen image"
>
{shouldUseNativeImage ? (
{hasImageError ? (
<div className="error">🖼</div>
) : shouldUseNativeImage ? (
<img
src={src}
alt=""
className={styles.viewerImage}
onError={(e) => {
e.currentTarget.style.display = "none";
(e.currentTarget.parentElement as HTMLElement).innerHTML =
'<div class="error">🖼</div>';
}}
onError={handleImageError}
/>
) : (
<Image
@@ -126,11 +137,7 @@ export function FullscreenImageViewer({
width={800}
height={800}
className={styles.viewerImage}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
(e.currentTarget.parentElement as HTMLElement).innerHTML =
'<div class="error">🖼</div>';
}}
onError={handleImageError}
/>
)}
</div>
+18 -8
View File
@@ -34,16 +34,26 @@ export function ImageBubble({
imagePaywalled = false,
}: ImageBubbleProps) {
const router = useRouter();
const [error, setError] = useState(false);
const { mediaUrl } = useCachedChatMediaUrl({
messageId,
remoteUrl: imageUrl,
kind: "image",
});
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
messageId,
remoteUrl: imageUrl,
kind: "image",
});
const src = normalizeChatImageSource(mediaUrl || imageUrl);
const canOpen = Boolean(messageId);
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
const error = errorSrc === src;
const handleImageError = () => {
if (isUsingCachedMedia) {
reportMediaError();
return;
}
setErrorSrc(src);
};
const openImage = () => {
if (!messageId) return;
@@ -73,7 +83,7 @@ export function ImageBubble({
className={styles.image}
src={src}
alt=""
onError={() => setError(true)}
onError={handleImageError}
/>
) : (
<Image
@@ -82,7 +92,7 @@ export function ImageBubble({
alt=""
width={240}
height={240}
onError={() => setError(true)}
onError={handleImageError}
/>
)}
{!error && imagePaywalled ? (
+19 -10
View File
@@ -27,12 +27,13 @@ export function VoiceBubble({
}: VoiceBubbleProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasError, setHasError] = useState(false);
const { mediaUrl } = useCachedChatMediaUrl({
messageId,
remoteUrl: audioUrl,
kind: "audio",
});
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
messageId,
remoteUrl: audioUrl,
kind: "audio",
});
useEffect(() => {
const audio = audioRef.current;
@@ -42,7 +43,11 @@ export function VoiceBubble({
const handlePause = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true);
const handleError = () => {
setHasError(true);
if (isUsingCachedMedia) {
reportMediaError();
return;
}
setErrorMediaUrl(mediaUrl);
setIsPlaying(false);
};
@@ -56,7 +61,9 @@ export function VoiceBubble({
audio.removeEventListener("play", handlePlay);
audio.removeEventListener("error", handleError);
};
}, []);
}, [isUsingCachedMedia, mediaUrl, reportMediaError]);
const hasError = errorMediaUrl === mediaUrl;
const handleToggle = () => {
if (locked) return;
@@ -70,7 +77,7 @@ export function VoiceBubble({
}
void audio.play().catch(() => {
setHasError(true);
setErrorMediaUrl(mediaUrl);
setIsPlaying(false);
});
};
@@ -125,7 +132,9 @@ export function VoiceBubble({
</>
) : null}
</div>
{!locked ? <audio ref={audioRef} src={mediaUrl} preload="metadata" /> : null}
{!locked ? (
<audio ref={audioRef} src={mediaUrl} preload="metadata" />
) : null}
</div>
);
}
@@ -109,10 +109,13 @@ export class ChatMediaCacheCoordinator {
);
}
const blob = await response.blob();
if (blob.size <= 0) {
throw new Error("Media download returned an empty blob.");
const bytes = await response.arrayBuffer();
if (bytes.byteLength <= 0) {
throw new Error("Media download returned empty bytes.");
}
const mimeType =
response.headers.get("content-type") ||
fallbackChatMediaMimeType(input.kind);
const saveResult = await this.mediaStorage.saveMedia({
cacheKey,
@@ -120,11 +123,8 @@ export class ChatMediaCacheCoordinator {
messageId: input.messageId,
kind: input.kind,
remoteUrl: input.remoteUrl,
blob,
mimeType:
blob.type ||
response.headers.get("content-type") ||
fallbackChatMediaMimeType(input.kind),
bytes,
mimeType,
});
if (Result.isErr(saveResult)) throw saveResult.error;
void requestBrowserPersistentStorageOnce();
@@ -69,12 +69,12 @@ export interface IChatRepository {
/** 获取本地消息数量。 */
getLocalMessageCount(): Promise<Result<number>>;
/** 获取本地缓存的图片 / 音频 Blob。 */
/** 获取本地缓存的图片 / 音频。 */
getCachedMedia(
input: ChatMediaLookupInput,
): Promise<Result<LocalChatMediaRow | null>>;
/** 下载并缓存远程图片 / 音频 Blob。 */
/** 下载并缓存远程图片 / 音频。 */
cacheRemoteMedia(
input: CacheRemoteChatMediaInput,
): Promise<Result<LocalChatMediaRow>>;
+4 -1
View File
@@ -39,7 +39,10 @@ export interface LocalChatMediaRow {
messageId: string;
kind: ChatMediaKind;
remoteUrl: string;
blob: Blob;
/** v3+ stores raw bytes instead of Blob to avoid iOS WebKit Blob URL issues. */
bytes?: ArrayBuffer;
/** Legacy v2 field. Read-only fallback for users who already have cached media. */
blob?: Blob;
mimeType: string;
byteSize: number;
createdAt: number;
@@ -16,7 +16,7 @@ export interface SaveLocalChatMediaInput {
messageId: string;
kind: ChatMediaKind;
remoteUrl: string;
blob: Blob;
bytes: ArrayBuffer;
mimeType: string;
}
@@ -44,9 +44,9 @@ export class LocalChatMediaStorage {
try {
const row = (await this.db.media.get(cacheKey)) ?? null;
if (row) {
await this.db.media.update(cacheKey, {
lastAccessedAt: Date.now(),
});
const normalizedRow = await this.normalizeLegacyMediaRow(row);
await this.db.media.update(cacheKey, { lastAccessedAt: Date.now() });
return Result.ok(normalizedRow);
}
return Result.ok(row);
} catch (e) {
@@ -64,9 +64,9 @@ export class LocalChatMediaStorage {
messageId: input.messageId,
kind: input.kind,
remoteUrl: input.remoteUrl,
blob: input.blob,
bytes: input.bytes,
mimeType: input.mimeType,
byteSize: input.blob.size,
byteSize: input.bytes.byteLength,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastAccessedAt: now,
@@ -85,4 +85,35 @@ export class LocalChatMediaStorage {
return Result.err(e);
}
}
private async normalizeLegacyMediaRow(
row: LocalChatMediaRow,
): Promise<LocalChatMediaRow> {
if (row.bytes && row.bytes.byteLength > 0) return row;
if (!row.blob || row.blob.size <= 0) return row;
const bytes = await row.blob.arrayBuffer();
const now = Date.now();
const normalizedRow: LocalChatMediaRow = {
cacheKey: row.cacheKey,
ownerKey: row.ownerKey,
messageId: row.messageId,
kind: row.kind,
remoteUrl: row.remoteUrl,
bytes,
mimeType:
row.mimeType || row.blob.type || fallbackMediaMimeType(row.kind),
byteSize: bytes.byteLength,
createdAt: row.createdAt,
updatedAt: now,
lastAccessedAt: now,
};
await this.db.media.put(normalizedRow);
return normalizedRow;
}
}
function fallbackMediaMimeType(kind: ChatMediaKind): string {
return kind === "image" ? "image/*" : "audio/mpeg";
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { LocalChatMediaRow } from "@/data/storage/chat";
import { localChatMediaRowToBlob } from "../chat_media_blob";
function makeMediaRow(
overrides: Partial<LocalChatMediaRow> = {},
): LocalChatMediaRow {
return {
cacheKey: "user:u1:msg-1:image",
ownerKey: "user:u1",
messageId: "msg-1",
kind: "image",
remoteUrl: "https://example.com/a.jpg",
mimeType: "image/jpeg",
byteSize: 3,
createdAt: 1,
updatedAt: 1,
lastAccessedAt: 1,
...overrides,
};
}
describe("chat media blob helpers", () => {
it("creates a blob from cached bytes", async () => {
const blob = localChatMediaRowToBlob(
makeMediaRow({
bytes: new Uint8Array([1, 2, 3]).buffer,
}),
);
if (!blob) throw new Error("Expected bytes row to produce a blob.");
expect(blob.type).toBe("image/jpeg");
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([
1, 2, 3,
]);
});
it("supports legacy blob rows", async () => {
const blob = localChatMediaRowToBlob(
makeMediaRow({
bytes: undefined,
blob: new Blob([new Uint8Array([4, 5])], { type: "image/png" }),
mimeType: "",
}),
);
if (!blob) throw new Error("Expected legacy blob row to produce a blob.");
expect(blob.type).toBe("image/png");
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([
4, 5,
]);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { LocalChatMediaRow } from "@/data/storage/chat";
export function localChatMediaRowToBlob(
row: LocalChatMediaRow,
): Blob | null {
if (row.bytes && row.bytes.byteLength > 0) {
const mimeType = row.mimeType || fallbackMediaMimeType(row.kind);
return new Blob([row.bytes], { type: mimeType });
}
if (row.blob && row.blob.size > 0) {
const mimeType =
row.mimeType || row.blob.type || fallbackMediaMimeType(row.kind);
return new Blob([row.blob], { type: mimeType });
}
return null;
}
function fallbackMediaMimeType(kind: LocalChatMediaRow["kind"]): string {
return kind === "image" ? "image/*" : "audio/mpeg";
}
+7 -2
View File
@@ -4,6 +4,8 @@ import type { ChatMediaKind } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Result, type Result as ResultT } from "@/utils";
import { localChatMediaRowToBlob } from "./chat_media_blob";
export interface ResolveCachedChatMediaBlobInput {
messageId: string;
kind: ChatMediaKind;
@@ -19,7 +21,8 @@ export async function resolveCachedChatMediaBlob(
): Promise<ResultT<Blob | null>> {
const result = await getChatRepository().getCachedMedia(input);
if (Result.isErr(result)) return result;
return Result.ok(result.data?.blob ?? null);
if (!result.data) return Result.ok(null);
return Result.ok(localChatMediaRowToBlob(result.data));
}
export async function cacheRemoteChatMediaBlob(
@@ -27,5 +30,7 @@ export async function cacheRemoteChatMediaBlob(
): Promise<ResultT<Blob>> {
const result = await getChatRepository().cacheRemoteMedia(input);
if (Result.isErr(result)) return result;
return Result.ok(result.data.blob);
const blob = localChatMediaRowToBlob(result.data);
if (!blob) return Result.err(new Error("Cached media has no readable bytes."));
return Result.ok(blob);
}
+103 -18
View File
@@ -1,9 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ChatMediaKind } from "@/data/dto/chat";
import { Result } from "@/utils";
import { BrowserDetector, Logger, PlatformDetector, Result } from "@/utils";
import {
cacheRemoteChatMediaBlob,
@@ -11,6 +11,8 @@ import {
} from "./chat_media_cache_client";
import { isCacheableRemoteChatMediaUrl } from "./chat_media_url";
const log = new Logger("LibChatUseCachedChatMediaUrl");
export interface UseCachedChatMediaUrlInput {
messageId?: string | null;
remoteUrl?: string | null;
@@ -20,11 +22,14 @@ export interface UseCachedChatMediaUrlInput {
export interface UseCachedChatMediaUrlOutput {
mediaUrl: string;
isUsingCachedMedia: boolean;
reportMediaError: () => void;
}
interface CachedMediaUrlState {
key: string;
objectUrl: string;
mediaUrl: string;
objectUrl: string | null;
source: "data-url" | "object-url";
}
export function useCachedChatMediaUrl({
@@ -35,6 +40,8 @@ export function useCachedChatMediaUrl({
const [cachedState, setCachedState] = useState<CachedMediaUrlState | null>(
null,
);
const cachedStateRef = useRef<CachedMediaUrlState | null>(null);
const [failedCacheKey, setFailedCacheKey] = useState<string | null>(null);
const fallbackUrl = remoteUrl ?? "";
const stateKey =
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
@@ -43,27 +50,28 @@ export function useCachedChatMediaUrl({
if (
!messageId ||
!remoteUrl ||
!isCacheableRemoteChatMediaUrl(remoteUrl)
!isCacheableRemoteChatMediaUrl(remoteUrl) ||
failedCacheKey === stateKey
) {
return;
}
let cancelled = false;
let objectUrl: string | null = null;
const applyBlob = (blob: Blob) => {
const nextObjectUrl = URL.createObjectURL(blob);
objectUrl = nextObjectUrl;
const applyBlob = async (blob: Blob) => {
const nextUrl = await createCachedMediaUrl(blob);
if (cancelled) {
URL.revokeObjectURL(nextObjectUrl);
revokeCachedMediaUrl(nextUrl);
return;
}
setCachedState((previous) => {
if (previous) URL.revokeObjectURL(previous.objectUrl);
return {
if (previous) revokeCachedMediaUrl(previous);
const nextState = {
key: stateKey,
objectUrl: nextObjectUrl,
...nextUrl,
};
cachedStateRef.current = nextState;
return nextState;
});
};
@@ -75,7 +83,7 @@ export function useCachedChatMediaUrl({
if (cancelled) return;
if (Result.isOk(cachedResult) && cachedResult.data) {
applyBlob(cachedResult.data);
await applyBlob(cachedResult.data);
return;
}
@@ -87,27 +95,104 @@ export function useCachedChatMediaUrl({
if (cancelled) return;
if (Result.isOk(cacheResult)) {
applyBlob(cacheResult.data);
await applyBlob(cacheResult.data);
}
};
void resolveCachedMedia();
void resolveCachedMedia().catch((error) => {
log.warn("[chat-media] cached media url resolve failed", {
messageId,
kind,
remoteUrl,
error,
});
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [kind, messageId, remoteUrl, stateKey]);
}, [failedCacheKey, kind, messageId, remoteUrl, stateKey]);
useEffect(() => {
return () => {
if (cachedStateRef.current) revokeCachedMediaUrl(cachedStateRef.current);
cachedStateRef.current = null;
};
}, []);
const reportMediaError = () => {
if (cachedState?.key !== stateKey) return;
log.warn("[chat-media] cached media url failed, fallback to remote url", {
messageId,
kind,
remoteUrl,
source: cachedState.source,
});
setFailedCacheKey(stateKey);
setCachedState((previous) => {
if (previous) revokeCachedMediaUrl(previous);
cachedStateRef.current = null;
return null;
});
};
if (cachedState?.key === stateKey) {
return {
mediaUrl: cachedState.objectUrl,
mediaUrl: cachedState.mediaUrl,
isUsingCachedMedia: true,
reportMediaError,
};
}
return {
mediaUrl: fallbackUrl,
isUsingCachedMedia: false,
reportMediaError,
};
}
async function createCachedMediaUrl(
blob: Blob,
): Promise<Omit<CachedMediaUrlState, "key">> {
if (shouldUseDataUrlForCachedMedia()) {
return {
mediaUrl: await blobToDataUrl(blob),
objectUrl: null,
source: "data-url",
};
}
const objectUrl = URL.createObjectURL(blob);
return {
mediaUrl: objectUrl,
objectUrl,
source: "object-url",
};
}
function revokeCachedMediaUrl(
state: Pick<CachedMediaUrlState, "objectUrl">,
): void {
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
}
function shouldUseDataUrlForCachedMedia(): boolean {
return PlatformDetector.isIOS() && BrowserDetector.isSafari();
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("Media blob could not be converted to a data URL."));
});
reader.addEventListener("error", () => {
reject(reader.error ?? new Error("Media blob data URL read failed."));
});
reader.readAsDataURL(blob);
});
}