fix(chat): make cached media load on safari
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user