diff --git a/src/app/chat/components/chat-media-image.tsx b/src/app/chat/components/chat-media-image.tsx new file mode 100644 index 00000000..8bede11e --- /dev/null +++ b/src/app/chat/components/chat-media-image.tsx @@ -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; + 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(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
🖼
; + } + + return ( + <> + {shouldUseNativeImage ? ( + {alt} + ) : fill ? ( + {alt} + ) : ( + {alt} + )} + {hasError ? null : children} + + ); +} diff --git a/src/app/chat/components/fullscreen-image-viewer.tsx b/src/app/chat/components/fullscreen-image-viewer.tsx index 228e7d61..c34aa5ca 100644 --- a/src/app/chat/components/fullscreen-image-viewer.tsx +++ b/src/app/chat/components/fullscreen-image-viewer.tsx @@ -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(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 (
- {shouldUseNativeImage ? ( - - ) : ( - - )} + ); } diff --git a/src/app/chat/components/image-bubble.tsx b/src/app/chat/components/image-bubble.tsx index 11a1cbeb..13053369 100644 --- a/src/app/chat/components/image-bubble.tsx +++ b/src/app/chat/components/image-bubble.tsx @@ -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(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 ? ( -
🖼
- ) : shouldUseNativeImage ? ( - - ) : ( - - )} - {!error && imagePaywalled ? ( -