feat(media): implement caching for chat media and update components to use cached media
This commit is contained in:
@@ -30,6 +30,13 @@
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.nativePaywallImage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.paywallScrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
/**
|
||||
* FullscreenImageViewer 全屏图片查看器
|
||||
*
|
||||
@@ -13,9 +14,11 @@ import { ChevronLeft } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
||||
import styles from "./fullscreen-image-viewer.module.css";
|
||||
|
||||
export interface FullscreenImageViewerProps {
|
||||
messageId?: string;
|
||||
imageUrl: string;
|
||||
imagePaywalled?: boolean;
|
||||
onUnlockImagePaywall?: () => void;
|
||||
@@ -23,11 +26,18 @@ export interface FullscreenImageViewerProps {
|
||||
}
|
||||
|
||||
export function FullscreenImageViewer({
|
||||
messageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
onUnlockImagePaywall,
|
||||
onClose,
|
||||
}: FullscreenImageViewerProps) {
|
||||
const { mediaUrl } = useCachedChatMediaUrl({
|
||||
messageId,
|
||||
remoteUrl: imageUrl,
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -36,9 +46,13 @@ export function FullscreenImageViewer({
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const src = imageUrl.startsWith("data:") || imageUrl.startsWith("http")
|
||||
? imageUrl
|
||||
: `data:image/png;base64,${imageUrl}`;
|
||||
const sourceUrl = mediaUrl || imageUrl;
|
||||
const src = sourceUrl.startsWith("data:") ||
|
||||
sourceUrl.startsWith("http") ||
|
||||
sourceUrl.startsWith("blob:")
|
||||
? sourceUrl
|
||||
: `data:image/png;base64,${sourceUrl}`;
|
||||
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
|
||||
|
||||
if (imagePaywalled) {
|
||||
return (
|
||||
@@ -47,13 +61,21 @@ export function FullscreenImageViewer({
|
||||
role="dialog"
|
||||
aria-label="Locked fullscreen image"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 540px) 100vw, 540px"
|
||||
className={styles.paywallImage}
|
||||
/>
|
||||
{shouldUseNativeImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={`${styles.nativePaywallImage} ${styles.paywallImage}`}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 540px) 100vw, 540px"
|
||||
className={styles.paywallImage}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.paywallScrim} aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
@@ -81,18 +103,35 @@ export function FullscreenImageViewer({
|
||||
role="dialog"
|
||||
aria-label="Fullscreen image"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
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>';
|
||||
}}
|
||||
/>
|
||||
{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>';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
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>';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isBrowserLocalImageSrc(src: string): boolean {
|
||||
return src.startsWith("blob:") || src.startsWith("data:");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
/**
|
||||
* ImageBubble 图片气泡
|
||||
*
|
||||
@@ -13,6 +14,7 @@ import { useState } from "react";
|
||||
|
||||
import { ROUTE_BUILDERS } from "@/router/routes";
|
||||
|
||||
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
||||
import styles from "./image-bubble.module.css";
|
||||
|
||||
export interface ImageBubbleProps {
|
||||
@@ -28,9 +30,15 @@ export function ImageBubble({
|
||||
}: ImageBubbleProps) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState(false);
|
||||
const { mediaUrl } = useCachedChatMediaUrl({
|
||||
messageId,
|
||||
remoteUrl: imageUrl,
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
const src = decodeBase64Image(imageUrl);
|
||||
const src = decodeBase64Image(mediaUrl || imageUrl);
|
||||
const canOpen = Boolean(messageId);
|
||||
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
|
||||
|
||||
const openImage = () => {
|
||||
if (!messageId) return;
|
||||
@@ -54,6 +62,13 @@ export function ImageBubble({
|
||||
>
|
||||
{error ? (
|
||||
<div className={styles.errorFallback}>🖼</div>
|
||||
) : shouldUseNativeImage ? (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={src}
|
||||
alt=""
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
className={styles.image}
|
||||
@@ -80,3 +95,7 @@ function decodeBase64Image(uri: string): string {
|
||||
// 裸 base64 字符串 → 包装成 data URI(PNG 兜底)
|
||||
return `data:image/png;base64,${uri}`;
|
||||
}
|
||||
|
||||
function isBrowserLocalImageSrc(src: string): boolean {
|
||||
return src.startsWith("blob:") || src.startsWith("data:");
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MessageContent({
|
||||
)}
|
||||
{hasAudio && audioUrl ? (
|
||||
<VoiceBubble
|
||||
messageId={messageId}
|
||||
audioUrl={audioUrl}
|
||||
isFromAI={isFromAI}
|
||||
locked={isLockedVoiceMessage}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { LockKeyhole, Pause, Play } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
||||
import styles from "./voice-bubble.module.css";
|
||||
|
||||
export interface VoiceBubbleProps {
|
||||
messageId?: string;
|
||||
audioUrl: string;
|
||||
isFromAI: boolean;
|
||||
locked?: boolean;
|
||||
@@ -15,6 +17,7 @@ export interface VoiceBubbleProps {
|
||||
}
|
||||
|
||||
export function VoiceBubble({
|
||||
messageId,
|
||||
audioUrl,
|
||||
isFromAI,
|
||||
locked = false,
|
||||
@@ -25,6 +28,11 @@ export function VoiceBubble({
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const { mediaUrl } = useCachedChatMediaUrl({
|
||||
messageId,
|
||||
remoteUrl: audioUrl,
|
||||
kind: "audio",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
@@ -117,7 +125,7 @@ export function VoiceBubble({
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{!locked ? <audio ref={audioRef} src={audioUrl} preload="metadata" /> : null}
|
||||
{!locked ? <audio ref={audioRef} src={mediaUrl} preload="metadata" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { LocalChatMediaKind } from "@/data/storage/chat";
|
||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
export interface UseCachedChatMediaUrlInput {
|
||||
messageId?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
kind: LocalChatMediaKind;
|
||||
}
|
||||
|
||||
export interface UseCachedChatMediaUrlOutput {
|
||||
mediaUrl: string;
|
||||
isUsingCachedMedia: boolean;
|
||||
}
|
||||
|
||||
interface CachedMediaUrlState {
|
||||
key: string;
|
||||
objectUrl: string;
|
||||
}
|
||||
|
||||
export function useCachedChatMediaUrl({
|
||||
messageId,
|
||||
remoteUrl,
|
||||
kind,
|
||||
}: UseCachedChatMediaUrlInput): UseCachedChatMediaUrlOutput {
|
||||
const [cachedState, setCachedState] = useState<CachedMediaUrlState | null>(
|
||||
null,
|
||||
);
|
||||
const fallbackUrl = remoteUrl ?? "";
|
||||
const stateKey =
|
||||
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageId || !remoteUrl || !isCacheableRemoteUrl(remoteUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const applyBlob = (blob: Blob) => {
|
||||
const nextObjectUrl = URL.createObjectURL(blob);
|
||||
objectUrl = nextObjectUrl;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(nextObjectUrl);
|
||||
return;
|
||||
}
|
||||
setCachedState((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous.objectUrl);
|
||||
return {
|
||||
key: stateKey,
|
||||
objectUrl: nextObjectUrl,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const resolveCachedMedia = async () => {
|
||||
const chatRepo = getChatRepository();
|
||||
const cachedResult = await chatRepo.getCachedMedia({ messageId, kind });
|
||||
if (cancelled) return;
|
||||
|
||||
if (Result.isOk(cachedResult) && cachedResult.data) {
|
||||
applyBlob(cachedResult.data.blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheResult = await chatRepo.cacheRemoteMedia({
|
||||
messageId,
|
||||
kind,
|
||||
remoteUrl,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
if (Result.isOk(cacheResult)) {
|
||||
applyBlob(cacheResult.data.blob);
|
||||
}
|
||||
};
|
||||
|
||||
void resolveCachedMedia();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [kind, messageId, remoteUrl, stateKey]);
|
||||
|
||||
if (cachedState?.key === stateKey) {
|
||||
return {
|
||||
mediaUrl: cachedState.objectUrl,
|
||||
isUsingCachedMedia: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mediaUrl: fallbackUrl,
|
||||
isUsingCachedMedia: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isCacheableRemoteUrl(url: string): boolean {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export function ChatImageViewerScreen({
|
||||
<>
|
||||
<MobileShell background="#000000">
|
||||
<FullscreenImageViewer
|
||||
messageId={messageId}
|
||||
imageUrl={message.imageUrl ?? ""}
|
||||
imagePaywalled={message.imagePaywalled === true}
|
||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||
|
||||
Reference in New Issue
Block a user