import { useEffect, useRef, useState } from 'react'; import { Icon } from '@/app/Icon'; import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks'; import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments'; interface ImageData { val?: string; ref?: string; mime?: string; width?: number; height?: number; /** Drafty.insertImage's own `filename` field -- present on every image this WEB client sent * (see sendImage in lib/tinode/attachments.ts). */ filename?: string; /** mobileAttachmentContentOf's `name` -- the equivalent field on an image sent from the iOS/Android * app, which flows through a completely different (non-Drafty) flat shape than the web client's * own `IM` entity above; MessageBubble passes that content straight through as this same ImageData * shape, so both need a name field here even though the two clients don't call it the same thing. */ name?: string; } /** Best-effort filename for the "保存图片" download below -- prefers whatever name the sender's own * client attached (see ImageData's own field comments), falling back to a generic name derived from * the mime type for the inline-base64/mobile-relay cases where neither field is set. */ function imageDownloadName(data: ImageData): string { if (data.filename) return data.filename; if (data.name) return data.name; const ext = data.mime?.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg'; return `image.${ext}`; } const LIGHTBOX_ZOOM_MIN = 1; const LIGHTBOX_ZOOM_MAX = 4; const LIGHTBOX_ZOOM_STEP = 0.5; /** Full-screen "查看大图" overlay opened by tapping a chat image -- `src` is the SAME blob/data URI * ImageAttachment already resolved (real full-resolution image; the 260x170 thumbnail is only a CSS * crop of it, not a separately-fetched smaller asset), so opening this needs no extra network work. * Zoom is CSS transform scale/translate on the `` itself, not a native browser zoom -- wheel * and the +/- buttons zoom around the center; dragging only does anything once zoomed in, otherwise * the image has nowhere to pan to and a drag gesture would just fight the backdrop's click-to-close. */ function ImageLightbox({ src, filename, onClose }: { src: string; filename: string; onClose: () => void }) { const [zoom, setZoom] = useState(LIGHTBOX_ZOOM_MIN); const [pan, setPan] = useState({ x: 0, y: 0 }); const imgRef = useRef(null); const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null); useEscapeKey(onClose); const clampZoom = (z: number) => Math.min(LIGHTBOX_ZOOM_MAX, Math.max(LIGHTBOX_ZOOM_MIN, z)); const applyZoom = (next: number) => { const clamped = clampZoom(next); setZoom(clamped); if (clamped === LIGHTBOX_ZOOM_MIN) setPan({ x: 0, y: 0 }); }; const zoomIn = () => applyZoom(zoom + LIGHTBOX_ZOOM_STEP); const zoomOut = () => applyZoom(zoom - LIGHTBOX_ZOOM_STEP); const toggleZoom = () => applyZoom(zoom > LIGHTBOX_ZOOM_MIN ? LIGHTBOX_ZOOM_MIN : LIGHTBOX_ZOOM_MAX); // Native listener with `passive:false`, not JSX `onWheel` -- React's synthetic wheel handler isn't // guaranteed non-passive across versions/browsers, and a passive listener's preventDefault() is // silently ignored, which would leave the page scrolling underneath the lightbox while "zooming". useEffect(() => { const el = imgRef.current; if (!el) return undefined; const onWheel = (e: WheelEvent) => { e.preventDefault(); applyZoom(zoom + (e.deltaY < 0 ? LIGHTBOX_ZOOM_STEP : -LIGHTBOX_ZOOM_STEP)); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); // eslint-disable-next-line react-hooks/exhaustive-deps -- re-binds each render so onWheel always // closes over the current `zoom`; the listener itself is cheap to re-add and this avoids a stale // closure that would zoom from an outdated level. }, [zoom]); const onMouseDown = (e: React.MouseEvent) => { if (zoom <= LIGHTBOX_ZOOM_MIN) return; e.stopPropagation(); dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y }; }; const onMouseMove = (e: React.MouseEvent) => { if (!dragRef.current) return; setPan({ x: dragRef.current.panX + (e.clientX - dragRef.current.startX), y: dragRef.current.panY + (e.clientY - dragRef.current.startY) }); }; const endDrag = () => { dragRef.current = null; }; return (
e.stopPropagation()}> {Math.round(zoom * 100)}% {/* Real anchor + `download`, same technique FileAttachment already relies on below -- works for both inline `data:` URIs (small images) and the `blob:` URL useAuthedBlobUrl fetches for larger ones, triggering a normal save-to-disk with no extra round trip. User report (老板, 2026-09-29): "图片也没有保存功能" -- right-click "另存为" technically already works on an ``, but wasn't discoverable enough; this puts an explicit, visible affordance here, same place every mainstream IM's own "查看大图" view puts it. */}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- pan/zoom drag target inside an already-dismissible dialog; alt="" is correct here, same reasoning as ImageAttachment's own thumbnail (chat image content has no meaningful alt text to give it). */} e.stopPropagation()} onDoubleClick={(e) => { e.stopPropagation(); toggleZoom(); }} onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={endDrag} onMouseLeave={endDrag} style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }} className={`max-h-[85vh] max-w-[85vw] select-none rounded object-contain ${zoom > LIGHTBOX_ZOOM_MIN ? 'cursor-grab active:cursor-grabbing' : ''}`} />
); } /** `locked` (MessageBubble's selectMode): a tap must only toggle the row's own selection, same * reasoning as QuoteBar/the @mention buttons going inert during bulk-select. Renders the same * thumbnail either way -- only the click behavior (and the lightbox it would open) is disabled, so * entering select mode doesn't visually change every image bubble in the list. */ export function ImageAttachment({ data, locked }: { data: ImageData; locked?: boolean }) { const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null; const remote = useAuthedBlobUrl(inline ? undefined : data.ref); const src = inline || remote.url; const [viewing, setViewing] = useState(false); if (!src) { return (
); } // README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box. const thumb = ; if (locked) return thumb; return ( <> {viewing ? setViewing(false)} /> : null} ); } interface FileData { val?: string; ref?: string; mime?: string; name?: string; size?: number; } /** `locked`: see ImageAttachment's own comment -- a tap during selectMode must only toggle the row, * not start a download (which `preventDefault` blocks for an ordinary click; middle-click/"open in * new tab" aren't specially guarded against, matching this codebase's existing level of rigor for * other locked-during-selectMode controls). */ export function FileAttachment({ data, locked }: { data: FileData; locked?: boolean }) { const style = fileTypeStyle(data.name, data.mime); const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref); const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null; const href = inline || remote.url || undefined; return ( { if (locked) { e.preventDefault(); } else if (remote.error) { e.preventDefault(); remote.retry(); } else if (!href) { e.preventDefault(); } }} className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble" > {data.name || '文件'} {typeof data.size === 'number' ? formatFileSize(data.size) : ''} ·{' '} {remote.error ? '下载失败,点击重试' : remote.loading ? '加载中…' : '点击下载'} ); } interface VideoData { val?: string; ref?: string; mime?: string; preview?: string; previewRef?: string; duration?: number; } function formatDuration(ms: number | undefined): string { const totalSeconds = Math.round((ms || 0) / 1000); return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`; } /** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls * until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via * favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as * an ``, so the static poster comes from the separate inline `preview` field (a real JPEG, * confirmed by its base64 signature) instead. Tapping swaps the poster for a real `