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";
|
"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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user