104 lines
2.7 KiB
TypeScript
104 lines
2.7 KiB
TypeScript
"use client";
|
||
/* eslint-disable @next/next/no-img-element */
|
||
/**
|
||
* ImageBubble 图片气泡
|
||
*
|
||
* 支持:
|
||
* - base64 data URI 解码(`data:image/png;base64,...`)
|
||
* - 点击 → 跳转动态路由打开全屏查看器
|
||
* - 错误占位符(broken image icon)
|
||
*/
|
||
import Image from "next/image";
|
||
import { useRouter } from "next/navigation";
|
||
import { useState } from "react";
|
||
|
||
import { ROUTE_BUILDERS } from "@/router/routes";
|
||
import {
|
||
isBrowserLocalChatImageSource,
|
||
normalizeChatImageSource,
|
||
} from "@/lib/chat/chat_media_url";
|
||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||
|
||
import { requestChatScrollSnapshotSave } from "../chat-scroll-session";
|
||
import styles from "./image-bubble.module.css";
|
||
|
||
export interface ImageBubbleProps {
|
||
messageId?: string;
|
||
imageUrl: string; // base64 data URI 或 URL
|
||
imagePaywalled?: boolean;
|
||
}
|
||
|
||
export function ImageBubble({
|
||
messageId,
|
||
imageUrl,
|
||
imagePaywalled = false,
|
||
}: ImageBubbleProps) {
|
||
const router = useRouter();
|
||
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);
|
||
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
||
const error = errorSrc === src;
|
||
|
||
const handleImageError = () => {
|
||
if (isUsingCachedMedia) {
|
||
reportMediaError();
|
||
return;
|
||
}
|
||
setErrorSrc(src);
|
||
};
|
||
|
||
const openImage = () => {
|
||
if (!messageId) return;
|
||
requestChatScrollSnapshotSave(messageId);
|
||
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`${styles.bubble} ${imagePaywalled ? styles.paywalled : ""}`}
|
||
onClick={openImage}
|
||
role={canOpen ? "button" : undefined}
|
||
tabIndex={canOpen ? 0 : undefined}
|
||
onKeyDown={(e) => {
|
||
if (!canOpen) return;
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
openImage();
|
||
}
|
||
}}
|
||
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}
|
||
</div>
|
||
);
|
||
}
|