import { useState, useCallback, memo, useMemo, useRef, useEffect, useLayoutEffect } from 'react'; import cs from 'classnames'; import { getImageDimensionsFromBlobImage } from '../../../../utils'; import classes from './ImageMessage.module.scss'; import ImagePreview from './ImagePreview'; import type { MessageInfo, ImageMessagePayload } from 'tuikit-atomicx-react/chat'; interface ImageMessageProps { message: MessageInfo; isLastMessage?: boolean; } interface ImageMessageContent { url: string; width?: number; height?: number; showName?: string; } const MAX_HEIGHT = 320; const MAX_WIDTH = 240; const MIN_HEIGHT = 50; const MIN_WIDTH = 120; const DEFAULT_ASPECT_RATIO = 4 / 3; const getInitialLoadingState = (url: string): 'loading' | 'loaded' | 'error' => { // 对于 blob URL,通常是刚创建的,需要加载 if (url.startsWith('blob:')) { return 'loading'; } // 尝试快速检测缓存(同步检测) const img = new Image(); img.src = url; // 如果图片已经完全加载(在缓存中),立即显示 if (img.complete && img.naturalWidth > 0) { return 'loaded'; } return 'loading'; }; const ImageMessage = (props: ImageMessageProps) => { const { message } = props; const payload = message.messagePayload as ImageMessagePayload; const messageContent: ImageMessageContent = { url: payload.originalImageURL || '', width: payload.originalImageWidth, height: payload.originalImageHeight, }; const [loadingState, setLoadingState] = useState<'loading' | 'loaded' | 'error'>(() => getInitialLoadingState(messageContent.url)); const [naturalSize, setNaturalSize] = useState<{ width: number; height: number; aspectRatio: number } | null>( messageContent.width && messageContent.height ? { width: messageContent.width, height: messageContent.height, aspectRatio: messageContent.width / messageContent.height, } : null, ); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const imageRef = useRef(null); const containerRef = useRef(null); const isBlobImage = messageContent.url.startsWith('blob'); // Set image src - rely on browser native loading="lazy" for lazy loading useEffect(() => { if (!imageRef.current) { return; } imageRef.current.src = messageContent.url; }, [messageContent.url]); // for blob images, try to get the actual dimensions useLayoutEffect(() => { const fetchBlobDimensions = async () => { if (isBlobImage && !naturalSize) { try { const dimensions = await getImageDimensionsFromBlobImage(messageContent.url); if (dimensions) { setNaturalSize({ width: dimensions.width, height: dimensions.height, aspectRatio: dimensions.width / dimensions.height, }); } } catch (_error) { setNaturalSize({ width: 400, height: 300, aspectRatio: DEFAULT_ASPECT_RATIO, }); } } }; fetchBlobDimensions(); }, [messageContent.url, isBlobImage, naturalSize]); // calculate the final display size const displaySize = useMemo(() => { // if there is no natural size information, use the default value if (!naturalSize) { return { width: Math.min(MAX_HEIGHT * DEFAULT_ASPECT_RATIO, MAX_WIDTH), height: MAX_HEIGHT, // use the maximum height, to avoid the problem that the size changes after loading aspectRatio: DEFAULT_ASPECT_RATIO, }; } const { width: originalWidth, height: originalHeight, aspectRatio } = naturalSize; // start from the original size let finalWidth = originalWidth; let finalHeight = originalHeight; // step 1: if the height exceeds the maximum height, shrink proportionally if (finalHeight > MAX_HEIGHT) { finalHeight = MAX_HEIGHT; finalWidth = finalHeight * aspectRatio; } // step 2: if the width exceeds the maximum width, shrink proportionally if (finalWidth > MAX_WIDTH) { finalWidth = MAX_WIDTH; finalHeight = finalWidth / aspectRatio; } // step 3: if the height is less than the minimum height, enlarge proportionally if (finalHeight < MIN_HEIGHT) { finalHeight = MIN_HEIGHT; finalWidth = finalHeight * aspectRatio; } // step 4: protect mobile Safari from rendering ultra-narrow media bubbles if (finalWidth < MIN_WIDTH) { finalWidth = MIN_WIDTH; } return { width: finalWidth, height: finalHeight, aspectRatio, }; }, [naturalSize]); // handle the image load event const handleImageLoad = useCallback((e: React.SyntheticEvent) => { const img = e.target as HTMLImageElement; // update the natural size (if not previously obtained) if (!naturalSize || isBlobImage) { setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight, aspectRatio: img.naturalWidth / img.naturalHeight, }); } setLoadingState('loaded'); }, [naturalSize, isBlobImage]); // handle the image load error const handleImageError = useCallback(() => { setLoadingState('error'); }, []); const handlePreviewOpen = useCallback(() => { if (loadingState !== 'loaded') { return; } if (!messageContent.url) { return; } setIsPreviewOpen(true); }, [loadingState, messageContent.url]); const handlePreviewClose = useCallback(() => { setIsPreviewOpen(false); }, []); return (
{/* skeleton */} {loadingState === 'loading' && (
)} {/* error state */} {loadingState === 'error' && (
Load failed
)} {/* image */} {messageContent.showName
); }; export default memo(ImageMessage, (prevProps, nextProps) => { const prevPayload = prevProps.message.messagePayload as ImageMessagePayload; const nextPayload = nextProps.message.messagePayload as ImageMessagePayload; return prevProps.message.msgID === nextProps.message.msgID && prevPayload.originalImageURL === nextPayload.originalImageURL; });