| 1 | import { useCallback, useEffect, useState } from "react"; |
| 2 | import { FileText, Folder, Image } from "lucide-react"; |
| 3 | import { app } from "../lib/bridge"; |
| 4 | import { sortDisplayAttachments, type DisplayAttachment } from "../lib/attachmentDisplay"; |
| 5 | import { useT } from "../lib/i18n"; |
| 6 | import { ImageViewer } from "./ImageViewer"; |
| 7 | import { Tooltip } from "./Tooltip"; |
| 8 | |
| 9 | function loadPreview(tabId: string, attachment: DisplayAttachment): Promise<string> { |
| 10 | const { path } = attachment; |
| 11 | if (path.startsWith("draft:") && typeof app.ReadDraftImageForTab === "function") { |
| 12 | return app.ReadDraftImageForTab(tabId, path.slice("draft:".length)); |
| 13 | } |
| 14 | if (path.startsWith("attachment:") && typeof app.ReadSessionAttachmentForTab === "function") { |
| 15 | const digest = path.slice("attachment:".length).slice(0, 64); |
| 16 | const mime = attachment.mime || (attachment.ext === "JPG" || attachment.ext === "JPEG" ? "image/jpeg" : `image/${attachment.ext.toLowerCase() || "png"}`); |
| 17 | return import("../lib/sessionAttachmentRead").then(module => module.readSessionAttachmentDataURL( |
| 18 | tabId, |
| 19 | digest, |
| 20 | mime, |
| 21 | (id, nextDigest, offset) => app.ReadSessionAttachmentForTab!(id, nextDigest, offset), |
| 22 | )); |
| 23 | } |
| 24 | return app.AttachmentDataURLForTab(tabId, path); |
| 25 | } |
| 26 | |
| 27 | function attachmentIcon(kind: DisplayAttachment["kind"]) { |
| 28 | if (kind === "image") return <Image size={15} />; |
| 29 | if (kind === "folder") return <Folder size={15} />; |
| 30 | return <FileText size={15} />; |
| 31 | } |
| 32 | |
| 33 | export function MessageAttachments({ attachments, tabId = "" }: { |
| 34 | attachments: DisplayAttachment[]; |
| 35 | tabId?: string; |
| 36 | }) { |
| 37 | const t = useT(); |
| 38 | const ordered = sortDisplayAttachments(attachments); |
| 39 | const [previews, setPreviews] = useState<Record<string, string>>({}); |
| 40 | const [viewer, setViewer] = useState({ open: false, url: "", name: "" }); |
| 41 | const openViewer = useCallback(async (attachment: DisplayAttachment) => { |
| 42 | let url = previews[attachment.path]; |
| 43 | if (!url) { |
| 44 | try { |
| 45 | url = await loadPreview(tabId, attachment); |
| 46 | setPreviews(previous => previous[attachment.path] ? previous : { ...previous, [attachment.path]: url }); |
| 47 | } catch { return; } |
| 48 | } |
| 49 | setViewer({ open: true, url, name: attachment.name }); |
| 50 | }, [previews, tabId]); |
| 51 | |
| 52 | const previewKey = ordered.filter(item => item.kind === "image" && item.source === "attachment").map(item => item.path).join("\n"); |
| 53 | useEffect(() => { |
| 54 | let cancelled = false; |
| 55 | for (const path of previewKey ? previewKey.split("\n") : []) { |
| 56 | if (previews[path]) continue; |
| 57 | const attachment = ordered.find(item => item.path === path); |
| 58 | if (!attachment) continue; |
| 59 | loadPreview(tabId, attachment).then(url => { |
| 60 | if (!cancelled) setPreviews(previous => previous[path] ? previous : { ...previous, [path]: url }); |
| 61 | }).catch(() => {}); |
| 62 | } |
| 63 | return () => { cancelled = true; }; |
| 64 | }, [previewKey, tabId]); |
| 65 | |
| 66 | return ( |
| 67 | <div className="msg-attachments" aria-label={t("msg.attachments")} data-transcript-selection-ignore> |
| 68 | {ordered.map((attachment, index) => { |
| 69 | const image = attachment.kind === "image"; |
| 70 | const card = ( |
| 71 | <div |
| 72 | className={`msg-attachment msg-attachment--${attachment.kind}`} |
| 73 | key={image ? undefined : `${attachment.path}:${index}`} |
| 74 | title={image ? undefined : attachment.path} |
| 75 | onClick={image ? () => openViewer(attachment) : undefined} |
| 76 | role={image ? "button" : undefined} |
| 77 | tabIndex={image ? 0 : undefined} |
| 78 | onKeyDown={image ? event => { |
| 79 | if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openViewer(attachment); } |
| 80 | } : undefined} |
| 81 | > |
| 82 | <span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true"> |
| 83 | {image && previews[attachment.path] ? <img src={previews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)} |
| 84 | </span> |
| 85 | <span className="msg-attachment__main"> |
| 86 | <span className="msg-attachment__name">{attachment.name}</span> |
| 87 | <span className="msg-attachment__meta"> |
| 88 | {attachment.kind === "folder" |
| 89 | ? t("msg.folderReference") |
| 90 | : `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : image ? t("msg.imageAttachment") : t("msg.fileAttachment")}`} |
| 91 | </span> |
| 92 | </span> |
| 93 | </div> |
| 94 | ); |
| 95 | return image ? <Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>{card}</Tooltip> : card; |
| 96 | })} |
| 97 | <ImageViewer open={viewer.open} imageUrl={viewer.url} imageName={viewer.name} onClose={() => setViewer(value => value.open ? { ...value, open: false } : value)} /> |
| 98 | </div> |
| 99 | ); |
| 100 | } |
| 101 |