feat(media): implement caching for chat media and update components to use cached media

This commit is contained in:
2026-06-30 15:59:33 +08:00
parent 78d8aae22e
commit ccd2d6bd48
15 changed files with 591 additions and 42 deletions
@@ -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:");
}
+20 -1
View File
@@ -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 URIPNG 兜底)
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}
+9 -1
View File
@@ -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>
);
}