fix(chat): make cached media load on safari
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isBrowserLocalChatImageSource,
|
isBrowserLocalChatImageSource,
|
||||||
@@ -39,11 +39,13 @@ export function FullscreenImageViewer({
|
|||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
onClose,
|
onClose,
|
||||||
}: FullscreenImageViewerProps) {
|
}: FullscreenImageViewerProps) {
|
||||||
const { mediaUrl } = useCachedChatMediaUrl({
|
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||||
messageId,
|
useCachedChatMediaUrl({
|
||||||
remoteUrl: imageUrl,
|
messageId,
|
||||||
kind: "image",
|
remoteUrl: imageUrl,
|
||||||
});
|
kind: "image",
|
||||||
|
});
|
||||||
|
const [imageErrorSrc, setImageErrorSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -55,6 +57,15 @@ export function FullscreenImageViewer({
|
|||||||
|
|
||||||
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
||||||
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
||||||
|
const hasImageError = imageErrorSrc === src;
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
if (isUsingCachedMedia) {
|
||||||
|
reportMediaError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImageErrorSrc(src);
|
||||||
|
};
|
||||||
|
|
||||||
if (imagePaywalled) {
|
if (imagePaywalled) {
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +79,7 @@ export function FullscreenImageViewer({
|
|||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
className={`${styles.nativePaywallImage} ${styles.paywallImage}`}
|
className={`${styles.nativePaywallImage} ${styles.paywallImage}`}
|
||||||
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
@@ -76,6 +88,7 @@ export function FullscreenImageViewer({
|
|||||||
fill
|
fill
|
||||||
sizes="(max-width: 540px) 100vw, 540px"
|
sizes="(max-width: 540px) 100vw, 540px"
|
||||||
className={styles.paywallImage}
|
className={styles.paywallImage}
|
||||||
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={styles.paywallScrim} aria-hidden="true" />
|
<div className={styles.paywallScrim} aria-hidden="true" />
|
||||||
@@ -108,16 +121,14 @@ export function FullscreenImageViewer({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Fullscreen image"
|
aria-label="Fullscreen image"
|
||||||
>
|
>
|
||||||
{shouldUseNativeImage ? (
|
{hasImageError ? (
|
||||||
|
<div className="error">🖼</div>
|
||||||
|
) : shouldUseNativeImage ? (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
className={styles.viewerImage}
|
className={styles.viewerImage}
|
||||||
onError={(e) => {
|
onError={handleImageError}
|
||||||
e.currentTarget.style.display = "none";
|
|
||||||
(e.currentTarget.parentElement as HTMLElement).innerHTML =
|
|
||||||
'<div class="error">🖼</div>';
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
@@ -126,11 +137,7 @@ export function FullscreenImageViewer({
|
|||||||
width={800}
|
width={800}
|
||||||
height={800}
|
height={800}
|
||||||
className={styles.viewerImage}
|
className={styles.viewerImage}
|
||||||
onError={(e) => {
|
onError={handleImageError}
|
||||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
|
||||||
(e.currentTarget.parentElement as HTMLElement).innerHTML =
|
|
||||||
'<div class="error">🖼</div>';
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,16 +34,26 @@ export function ImageBubble({
|
|||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
}: ImageBubbleProps) {
|
}: ImageBubbleProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState(false);
|
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
||||||
const { mediaUrl } = useCachedChatMediaUrl({
|
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||||
messageId,
|
useCachedChatMediaUrl({
|
||||||
remoteUrl: imageUrl,
|
messageId,
|
||||||
kind: "image",
|
remoteUrl: imageUrl,
|
||||||
});
|
kind: "image",
|
||||||
|
});
|
||||||
|
|
||||||
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
||||||
const canOpen = Boolean(messageId);
|
const canOpen = Boolean(messageId);
|
||||||
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
||||||
|
const error = errorSrc === src;
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
if (isUsingCachedMedia) {
|
||||||
|
reportMediaError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorSrc(src);
|
||||||
|
};
|
||||||
|
|
||||||
const openImage = () => {
|
const openImage = () => {
|
||||||
if (!messageId) return;
|
if (!messageId) return;
|
||||||
@@ -73,7 +83,7 @@ export function ImageBubble({
|
|||||||
className={styles.image}
|
className={styles.image}
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
onError={() => setError(true)}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
@@ -82,7 +92,7 @@ export function ImageBubble({
|
|||||||
alt=""
|
alt=""
|
||||||
width={240}
|
width={240}
|
||||||
height={240}
|
height={240}
|
||||||
onError={() => setError(true)}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!error && imagePaywalled ? (
|
{!error && imagePaywalled ? (
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ export function VoiceBubble({
|
|||||||
}: VoiceBubbleProps) {
|
}: VoiceBubbleProps) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
|
||||||
const { mediaUrl } = useCachedChatMediaUrl({
|
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||||
messageId,
|
useCachedChatMediaUrl({
|
||||||
remoteUrl: audioUrl,
|
messageId,
|
||||||
kind: "audio",
|
remoteUrl: audioUrl,
|
||||||
});
|
kind: "audio",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@@ -42,7 +43,11 @@ export function VoiceBubble({
|
|||||||
const handlePause = () => setIsPlaying(false);
|
const handlePause = () => setIsPlaying(false);
|
||||||
const handlePlay = () => setIsPlaying(true);
|
const handlePlay = () => setIsPlaying(true);
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setHasError(true);
|
if (isUsingCachedMedia) {
|
||||||
|
reportMediaError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMediaUrl(mediaUrl);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +61,9 @@ export function VoiceBubble({
|
|||||||
audio.removeEventListener("play", handlePlay);
|
audio.removeEventListener("play", handlePlay);
|
||||||
audio.removeEventListener("error", handleError);
|
audio.removeEventListener("error", handleError);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isUsingCachedMedia, mediaUrl, reportMediaError]);
|
||||||
|
|
||||||
|
const hasError = errorMediaUrl === mediaUrl;
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
@@ -70,7 +77,7 @@ export function VoiceBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
void audio.play().catch(() => {
|
void audio.play().catch(() => {
|
||||||
setHasError(true);
|
setErrorMediaUrl(mediaUrl);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -125,7 +132,9 @@ export function VoiceBubble({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!locked ? <audio ref={audioRef} src={mediaUrl} preload="metadata" /> : null}
|
{!locked ? (
|
||||||
|
<audio ref={audioRef} src={mediaUrl} preload="metadata" />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,10 +109,13 @@ export class ChatMediaCacheCoordinator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const bytes = await response.arrayBuffer();
|
||||||
if (blob.size <= 0) {
|
if (bytes.byteLength <= 0) {
|
||||||
throw new Error("Media download returned an empty blob.");
|
throw new Error("Media download returned empty bytes.");
|
||||||
}
|
}
|
||||||
|
const mimeType =
|
||||||
|
response.headers.get("content-type") ||
|
||||||
|
fallbackChatMediaMimeType(input.kind);
|
||||||
|
|
||||||
const saveResult = await this.mediaStorage.saveMedia({
|
const saveResult = await this.mediaStorage.saveMedia({
|
||||||
cacheKey,
|
cacheKey,
|
||||||
@@ -120,11 +123,8 @@ export class ChatMediaCacheCoordinator {
|
|||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
remoteUrl: input.remoteUrl,
|
remoteUrl: input.remoteUrl,
|
||||||
blob,
|
bytes,
|
||||||
mimeType:
|
mimeType,
|
||||||
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 requestBrowserPersistentStorageOnce();
|
void requestBrowserPersistentStorageOnce();
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ export interface IChatRepository {
|
|||||||
/** 获取本地消息数量。 */
|
/** 获取本地消息数量。 */
|
||||||
getLocalMessageCount(): Promise<Result<number>>;
|
getLocalMessageCount(): Promise<Result<number>>;
|
||||||
|
|
||||||
/** 获取本地缓存的图片 / 音频 Blob。 */
|
/** 获取本地缓存的图片 / 音频。 */
|
||||||
getCachedMedia(
|
getCachedMedia(
|
||||||
input: ChatMediaLookupInput,
|
input: ChatMediaLookupInput,
|
||||||
): Promise<Result<LocalChatMediaRow | null>>;
|
): Promise<Result<LocalChatMediaRow | null>>;
|
||||||
|
|
||||||
/** 下载并缓存远程图片 / 音频 Blob。 */
|
/** 下载并缓存远程图片 / 音频。 */
|
||||||
cacheRemoteMedia(
|
cacheRemoteMedia(
|
||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
): Promise<Result<LocalChatMediaRow>>;
|
): Promise<Result<LocalChatMediaRow>>;
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ export interface LocalChatMediaRow {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
kind: ChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
remoteUrl: string;
|
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;
|
mimeType: string;
|
||||||
byteSize: number;
|
byteSize: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface SaveLocalChatMediaInput {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
kind: ChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
blob: Blob;
|
bytes: ArrayBuffer;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +44,9 @@ export class LocalChatMediaStorage {
|
|||||||
try {
|
try {
|
||||||
const row = (await this.db.media.get(cacheKey)) ?? null;
|
const row = (await this.db.media.get(cacheKey)) ?? null;
|
||||||
if (row) {
|
if (row) {
|
||||||
await this.db.media.update(cacheKey, {
|
const normalizedRow = await this.normalizeLegacyMediaRow(row);
|
||||||
lastAccessedAt: Date.now(),
|
await this.db.media.update(cacheKey, { lastAccessedAt: Date.now() });
|
||||||
});
|
return Result.ok(normalizedRow);
|
||||||
}
|
}
|
||||||
return Result.ok(row);
|
return Result.ok(row);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -64,9 +64,9 @@ export class LocalChatMediaStorage {
|
|||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
remoteUrl: input.remoteUrl,
|
remoteUrl: input.remoteUrl,
|
||||||
blob: input.blob,
|
bytes: input.bytes,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
byteSize: input.blob.size,
|
byteSize: input.bytes.byteLength,
|
||||||
createdAt: existing?.createdAt ?? now,
|
createdAt: existing?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastAccessedAt: now,
|
lastAccessedAt: now,
|
||||||
@@ -85,4 +85,35 @@ export class LocalChatMediaStorage {
|
|||||||
return Result.err(e);
|
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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
import { Result, type Result as ResultT } from "@/utils";
|
import { Result, type Result as ResultT } from "@/utils";
|
||||||
|
|
||||||
|
import { localChatMediaRowToBlob } from "./chat_media_blob";
|
||||||
|
|
||||||
export interface ResolveCachedChatMediaBlobInput {
|
export interface ResolveCachedChatMediaBlobInput {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
kind: ChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
@@ -19,7 +21,8 @@ export async function resolveCachedChatMediaBlob(
|
|||||||
): Promise<ResultT<Blob | null>> {
|
): Promise<ResultT<Blob | null>> {
|
||||||
const result = await getChatRepository().getCachedMedia(input);
|
const result = await getChatRepository().getCachedMedia(input);
|
||||||
if (Result.isErr(result)) return result;
|
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(
|
export async function cacheRemoteChatMediaBlob(
|
||||||
@@ -27,5 +30,7 @@ export async function cacheRemoteChatMediaBlob(
|
|||||||
): Promise<ResultT<Blob>> {
|
): Promise<ResultT<Blob>> {
|
||||||
const result = await getChatRepository().cacheRemoteMedia(input);
|
const result = await getChatRepository().cacheRemoteMedia(input);
|
||||||
if (Result.isErr(result)) return result;
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { ChatMediaKind } from "@/data/dto/chat";
|
import type { ChatMediaKind } from "@/data/dto/chat";
|
||||||
import { Result } from "@/utils";
|
import { BrowserDetector, Logger, PlatformDetector, Result } from "@/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cacheRemoteChatMediaBlob,
|
cacheRemoteChatMediaBlob,
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from "./chat_media_cache_client";
|
} from "./chat_media_cache_client";
|
||||||
import { isCacheableRemoteChatMediaUrl } from "./chat_media_url";
|
import { isCacheableRemoteChatMediaUrl } from "./chat_media_url";
|
||||||
|
|
||||||
|
const log = new Logger("LibChatUseCachedChatMediaUrl");
|
||||||
|
|
||||||
export interface UseCachedChatMediaUrlInput {
|
export interface UseCachedChatMediaUrlInput {
|
||||||
messageId?: string | null;
|
messageId?: string | null;
|
||||||
remoteUrl?: string | null;
|
remoteUrl?: string | null;
|
||||||
@@ -20,11 +22,14 @@ export interface UseCachedChatMediaUrlInput {
|
|||||||
export interface UseCachedChatMediaUrlOutput {
|
export interface UseCachedChatMediaUrlOutput {
|
||||||
mediaUrl: string;
|
mediaUrl: string;
|
||||||
isUsingCachedMedia: boolean;
|
isUsingCachedMedia: boolean;
|
||||||
|
reportMediaError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedMediaUrlState {
|
interface CachedMediaUrlState {
|
||||||
key: string;
|
key: string;
|
||||||
objectUrl: string;
|
mediaUrl: string;
|
||||||
|
objectUrl: string | null;
|
||||||
|
source: "data-url" | "object-url";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCachedChatMediaUrl({
|
export function useCachedChatMediaUrl({
|
||||||
@@ -35,6 +40,8 @@ export function useCachedChatMediaUrl({
|
|||||||
const [cachedState, setCachedState] = useState<CachedMediaUrlState | null>(
|
const [cachedState, setCachedState] = useState<CachedMediaUrlState | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const cachedStateRef = useRef<CachedMediaUrlState | null>(null);
|
||||||
|
const [failedCacheKey, setFailedCacheKey] = useState<string | null>(null);
|
||||||
const fallbackUrl = remoteUrl ?? "";
|
const fallbackUrl = remoteUrl ?? "";
|
||||||
const stateKey =
|
const stateKey =
|
||||||
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
|
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
|
||||||
@@ -43,27 +50,28 @@ export function useCachedChatMediaUrl({
|
|||||||
if (
|
if (
|
||||||
!messageId ||
|
!messageId ||
|
||||||
!remoteUrl ||
|
!remoteUrl ||
|
||||||
!isCacheableRemoteChatMediaUrl(remoteUrl)
|
!isCacheableRemoteChatMediaUrl(remoteUrl) ||
|
||||||
|
failedCacheKey === stateKey
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let objectUrl: string | null = null;
|
|
||||||
|
|
||||||
const applyBlob = (blob: Blob) => {
|
const applyBlob = async (blob: Blob) => {
|
||||||
const nextObjectUrl = URL.createObjectURL(blob);
|
const nextUrl = await createCachedMediaUrl(blob);
|
||||||
objectUrl = nextObjectUrl;
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
URL.revokeObjectURL(nextObjectUrl);
|
revokeCachedMediaUrl(nextUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCachedState((previous) => {
|
setCachedState((previous) => {
|
||||||
if (previous) URL.revokeObjectURL(previous.objectUrl);
|
if (previous) revokeCachedMediaUrl(previous);
|
||||||
return {
|
const nextState = {
|
||||||
key: stateKey,
|
key: stateKey,
|
||||||
objectUrl: nextObjectUrl,
|
...nextUrl,
|
||||||
};
|
};
|
||||||
|
cachedStateRef.current = nextState;
|
||||||
|
return nextState;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +83,7 @@ export function useCachedChatMediaUrl({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (Result.isOk(cachedResult) && cachedResult.data) {
|
if (Result.isOk(cachedResult) && cachedResult.data) {
|
||||||
applyBlob(cachedResult.data);
|
await applyBlob(cachedResult.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,27 +95,104 @@ export function useCachedChatMediaUrl({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (Result.isOk(cacheResult)) {
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
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) {
|
if (cachedState?.key === stateKey) {
|
||||||
return {
|
return {
|
||||||
mediaUrl: cachedState.objectUrl,
|
mediaUrl: cachedState.mediaUrl,
|
||||||
isUsingCachedMedia: true,
|
isUsingCachedMedia: true,
|
||||||
|
reportMediaError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaUrl: fallbackUrl,
|
mediaUrl: fallbackUrl,
|
||||||
isUsingCachedMedia: false,
|
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