refactor(chat): share media image rendering

This commit is contained in:
2026-07-09 14:05:43 +08:00
parent 3f1e4a0936
commit a998a80491
3 changed files with 137 additions and 117 deletions
@@ -0,0 +1,104 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import Image from "next/image";
import type { MouseEventHandler, ReactNode } from "react";
import { useState } from "react";
import {
isBrowserLocalChatImageSource,
normalizeChatImageSource,
} from "@/lib/chat/chat_media_url";
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
export interface ChatMediaImageProps {
messageId?: string;
remoteUrl: string;
alt?: string;
className?: string;
nativeClassName?: string;
errorClassName?: string;
width?: number;
height?: number;
fill?: boolean;
sizes?: string;
showErrorFallback?: boolean;
onClick?: MouseEventHandler<HTMLImageElement>;
children?: ReactNode;
}
export function ChatMediaImage({
messageId,
remoteUrl,
alt = "",
className,
nativeClassName,
errorClassName,
width = 240,
height = 240,
fill = false,
sizes,
showErrorFallback = true,
onClick,
children,
}: ChatMediaImageProps) {
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
messageId,
remoteUrl,
kind: "image",
});
const src = normalizeChatImageSource(mediaUrl || remoteUrl);
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
const hasError = errorSrc === src;
const handleImageError = () => {
if (isUsingCachedMedia) {
reportMediaError();
return;
}
setErrorSrc(src);
};
if (hasError && showErrorFallback) {
return <div className={errorClassName}>🖼</div>;
}
return (
<>
{shouldUseNativeImage ? (
<img
className={nativeClassName ?? className}
src={src}
alt={alt}
onError={handleImageError}
onClick={onClick}
/>
) : fill ? (
<Image
className={className}
src={src}
alt={alt}
fill
sizes={sizes}
onError={handleImageError}
onClick={onClick}
/>
) : (
<Image
className={className}
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
onError={handleImageError}
onClick={onClick}
/>
)}
{hasError ? null : children}
</>
);
}
@@ -1,5 +1,4 @@
"use client"; "use client";
/* eslint-disable @next/next/no-img-element */
/** /**
* FullscreenImageViewer 全屏图片查看器 * FullscreenImageViewer 全屏图片查看器
* *
@@ -11,15 +10,9 @@
* - 双指缩放(CSS `touch-action: pinch-zoom` * - 双指缩放(CSS `touch-action: pinch-zoom`
*/ */
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import Image from "next/image"; import { useEffect } from "react";
import { useEffect, useState } from "react";
import {
isBrowserLocalChatImageSource,
normalizeChatImageSource,
} from "@/lib/chat/chat_media_url";
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
import { ChatMediaImage } from "./chat-media-image";
import styles from "./fullscreen-image-viewer.module.css"; import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps { export interface FullscreenImageViewerProps {
@@ -39,14 +32,6 @@ export function FullscreenImageViewer({
onUnlockImagePaywall, onUnlockImagePaywall,
onClose, onClose,
}: FullscreenImageViewerProps) { }: FullscreenImageViewerProps) {
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
messageId,
remoteUrl: imageUrl,
kind: "image",
});
const [imageErrorSrc, setImageErrorSrc] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -55,18 +40,6 @@ export function FullscreenImageViewer({
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [onClose]); }, [onClose]);
const src = normalizeChatImageSource(mediaUrl || imageUrl);
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
const hasImageError = imageErrorSrc === src;
const handleImageError = () => {
if (isUsingCachedMedia) {
reportMediaError();
return;
}
setImageErrorSrc(src);
};
if (imagePaywalled) { if (imagePaywalled) {
return ( return (
<div <div
@@ -75,23 +48,15 @@ export function FullscreenImageViewer({
aria-modal="true" aria-modal="true"
aria-label="Locked fullscreen image" aria-label="Locked fullscreen image"
> >
{shouldUseNativeImage ? ( <ChatMediaImage
<img messageId={messageId}
src={src} remoteUrl={imageUrl}
alt="" className={styles.paywallImage}
className={`${styles.nativePaywallImage} ${styles.paywallImage}`} nativeClassName={`${styles.nativePaywallImage} ${styles.paywallImage}`}
onError={handleImageError} fill
/> sizes="(max-width: 540px) 100vw, 540px"
) : ( showErrorFallback={false}
<Image />
src={src}
alt=""
fill
sizes="(max-width: 540px) 100vw, 540px"
className={styles.paywallImage}
onError={handleImageError}
/>
)}
<div className={styles.paywallScrim} aria-hidden="true" /> <div className={styles.paywallScrim} aria-hidden="true" />
<button <button
type="button" type="button"
@@ -131,27 +96,15 @@ export function FullscreenImageViewer({
> >
<ChevronLeft size={34} strokeWidth={2.5} aria-hidden="true" /> <ChevronLeft size={34} strokeWidth={2.5} aria-hidden="true" />
</button> </button>
{hasImageError ? ( <ChatMediaImage
<div className="error">🖼</div> messageId={messageId}
) : shouldUseNativeImage ? ( remoteUrl={imageUrl}
<img className={styles.viewerImage}
src={src} errorClassName="error"
alt="" width={800}
className={styles.viewerImage} height={800}
onError={handleImageError} onClick={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()} />
/>
) : (
<Image
src={src}
alt=""
width={800}
height={800}
className={styles.viewerImage}
onError={handleImageError}
onClick={(event) => event.stopPropagation()}
/>
)}
</div> </div>
); );
} }
+13 -50
View File
@@ -1,5 +1,4 @@
"use client"; "use client";
/* eslint-disable @next/next/no-img-element */
/** /**
* ImageBubble 图片气泡 * ImageBubble 图片气泡
* *
@@ -8,15 +7,8 @@
* - 点击 → 通知聊天页打开页内全屏查看器 * - 点击 → 通知聊天页打开页内全屏查看器
* - 错误占位符(broken image icon * - 错误占位符(broken image icon
*/ */
import Image from "next/image";
import { useState } from "react";
import {
isBrowserLocalChatImageSource,
normalizeChatImageSource,
} from "@/lib/chat/chat_media_url";
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
import { ChatMediaImage } from "./chat-media-image";
import styles from "./image-bubble.module.css"; import styles from "./image-bubble.module.css";
export interface ImageBubbleProps { export interface ImageBubbleProps {
@@ -32,26 +24,7 @@ export function ImageBubble({
imagePaywalled = false, imagePaywalled = false,
onOpenImage, onOpenImage,
}: ImageBubbleProps) { }: ImageBubbleProps) {
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 && onOpenImage); const canOpen = Boolean(messageId && onOpenImage);
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
const error = errorSrc === src;
const handleImageError = () => {
if (isUsingCachedMedia) {
reportMediaError();
return;
}
setErrorSrc(src);
};
const openImage = () => { const openImage = () => {
if (!messageId || !onOpenImage) return; if (!messageId || !onOpenImage) return;
@@ -73,28 +46,18 @@ export function ImageBubble({
}} }}
aria-label={canOpen ? "Open image in fullscreen" : undefined} aria-label={canOpen ? "Open image in fullscreen" : undefined}
> >
{error ? ( <ChatMediaImage
<div className={styles.errorFallback}>🖼</div> messageId={messageId}
) : shouldUseNativeImage ? ( remoteUrl={imageUrl}
<img className={styles.image}
className={styles.image} errorClassName={styles.errorFallback}
src={src} width={240}
alt="" height={240}
onError={handleImageError} >
/> {imagePaywalled ? (
) : ( <div className={styles.paywallOverlay} aria-hidden="true" />
<Image ) : null}
className={styles.image} </ChatMediaImage>
src={src}
alt=""
width={240}
height={240}
onError={handleImageError}
/>
)}
{!error && imagePaywalled ? (
<div className={styles.paywallOverlay} aria-hidden="true" />
) : null}
</div> </div>
); );
} }