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