refactor(chat): share media image rendering
This commit is contained in:
@@ -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=""
|
||||
fill
|
||||
sizes="(max-width: 540px) 100vw, 540px"
|
||||
className={styles.paywallImage}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
<ChatMediaImage
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.paywallImage}
|
||||
nativeClassName={`${styles.nativePaywallImage} ${styles.paywallImage}`}
|
||||
fill
|
||||
sizes="(max-width: 540px) 100vw, 540px"
|
||||
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=""
|
||||
className={styles.viewerImage}
|
||||
onError={handleImageError}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
width={800}
|
||||
height={800}
|
||||
className={styles.viewerImage}
|
||||
onError={handleImageError}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<ChatMediaImage
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.viewerImage}
|
||||
errorClassName="error"
|
||||
width={800}
|
||||
height={800}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
className={styles.image}
|
||||
src={src}
|
||||
alt=""
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={src}
|
||||
alt=""
|
||||
width={240}
|
||||
height={240}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
{!error && imagePaywalled ? (
|
||||
<div className={styles.paywallOverlay} aria-hidden="true" />
|
||||
) : null}
|
||||
<ChatMediaImage
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.image}
|
||||
errorClassName={styles.errorFallback}
|
||||
width={240}
|
||||
height={240}
|
||||
>
|
||||
{imagePaywalled ? (
|
||||
<div className={styles.paywallOverlay} aria-hidden="true" />
|
||||
) : null}
|
||||
</ChatMediaImage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user