feat: fullscreen attachment gallery with image navigation

This commit is contained in:
rustmailer
2026-06-26 19:13:14 +08:00
parent cfb8172607
commit 0d37825625
4 changed files with 269 additions and 160 deletions
+12 -3
View File
@@ -30,8 +30,11 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
>(({ className, children, ...props }, ref) => { hideClose?: boolean;
hideFullscreen?: boolean;
}
>(({ className, children, hideClose, hideFullscreen, ...props }, ref) => {
const [isFullscreen, setIsFullscreen] = React.useState(false); const [isFullscreen, setIsFullscreen] = React.useState(false);
return <DialogPortal> return <DialogPortal>
<DialogOverlay> <DialogOverlay>
@@ -45,17 +48,23 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
{(!hideClose || !hideFullscreen) && (
<div className='absolute right-4 top-4 flex items-center gap-2'> <div className='absolute right-4 top-4 flex items-center gap-2'>
{isFullscreen ? ( {!hideFullscreen && (
isFullscreen ? (
<Minimize onClick={() => setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' /> <Minimize onClick={() => setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' />
) : ( ) : (
<Maximize onClick={() => setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' /> <Maximize onClick={() => setIsFullscreen(!isFullscreen)} className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground h-4 w-4' />
)
)} )}
{!hideClose && (
<DialogPrimitive.Close className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'> <DialogPrimitive.Close className='rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'>
<X className='h-4 w-4' /> <X className='h-4 w-4' />
<span className='sr-only'>Close</span> <span className='sr-only'>Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)}
</div> </div>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogOverlay> </DialogOverlay>
</DialogPortal> </DialogPortal>
@@ -16,9 +16,12 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Download, FileIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; import {
Download, FileIcon, ZoomIn, ZoomOut, RotateCcw,
ChevronLeft, ChevronRight, X,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Dialog, DialogContent } from '@/components/ui/dialog';
@@ -34,6 +37,12 @@ import { getFileConfig } from './mail-message-view';
const PREVIEWABLE_IMAGE = /^image\/(png|jpeg|gif|webp|svg\+xml)$/; const PREVIEWABLE_IMAGE = /^image\/(png|jpeg|gif|webp|svg\+xml)$/;
const PREVIEWABLE_TEXT = /^(text\/(plain|csv|html|xml|css|javascript|markdown)|application\/(json|xml|javascript|x-httpd-php|x-sh|x-perl|x-python|x-ruby))$/; const PREVIEWABLE_TEXT = /^(text\/(plain|csv|html|xml|css|javascript|markdown)|application\/(json|xml|javascript|x-httpd-php|x-sh|x-perl|x-python|x-ruby))$/;
export interface PreviewAttachment {
content_hash: string;
file_type: string;
filename: string;
}
interface AttachmentPreviewProps { interface AttachmentPreviewProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -42,6 +51,10 @@ interface AttachmentPreviewProps {
contentHash: string; contentHash: string;
contentType: string; contentType: string;
fileName: string; fileName: string;
/** Full attachment list for gallery navigation (optional). */
attachments?: PreviewAttachment[];
/** Index of the current attachment within `attachments`. */
attachmentIndex?: number;
} }
function isImagePreview(contentType: string) { function isImagePreview(contentType: string) {
@@ -64,21 +77,76 @@ export default function AttachmentPreview({
contentHash, contentHash,
contentType, contentType,
fileName, fileName,
attachments,
attachmentIndex,
}: AttachmentPreviewProps) { }: AttachmentPreviewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [blobUrl, setBlobUrl] = useState<string | null>(null); const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [textContent, setTextContent] = useState<string | null>(null); const [textContent, setTextContent] = useState<string | null>(null);
const [imageZoom, setImageZoom] = useState(1); const [imageZoom, setImageZoom] = useState(1);
// ── Gallery state ──────────────────────────────────────────────
// When attachments list is provided, compute image-only indices for navigation.
const imageIndices = useMemo(() => {
if (!attachments) return [];
return attachments
.map((a, i) => (isImagePreview(a.file_type) ? i : -1))
.filter((i) => i >= 0);
}, [attachments]);
const [currentIndex, setCurrentIndex] = useState(attachmentIndex ?? 0);
// Reset to the clicked attachment every time the dialog opens.
useEffect(() => {
if (open) {
setCurrentIndex(attachmentIndex ?? 0);
}
}, [open, attachmentIndex]);
// Resolve which attachment to display.
const resolved = useMemo(() => {
if (attachments && currentIndex < attachments.length) {
const a = attachments[currentIndex];
return {
contentHash: a.content_hash,
contentType: a.file_type,
fileName: a.filename,
};
}
return { contentHash, contentType, fileName };
}, [attachments, currentIndex, contentHash, contentType, fileName]);
// Position within image-only list (for "3 / 12" counter).
const imagePos = imageIndices.indexOf(currentIndex); // -1 if not an image
const imageTotal = imageIndices.length;
const goPrev = useCallback(() => {
if (imagePos > 0) setCurrentIndex(imageIndices[imagePos - 1]);
}, [imagePos, imageIndices]);
const goNext = useCallback(() => {
if (imagePos < imageTotal - 1) setCurrentIndex(imageIndices[imagePos + 1]);
}, [imagePos, imageTotal, imageIndices]);
// Keyboard navigation
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') goPrev();
else if (e.key === 'ArrowRight') goNext();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, goPrev, goNext]);
// ── Fetch preview blob ─────────────────────────────────────────
const previewMutation = useMutation({ const previewMutation = useMutation({
mutationFn: () => preview_attachment(accountId, envelopeId, contentHash), mutationFn: () => preview_attachment(accountId, envelopeId, resolved.contentHash),
onSuccess: (blob) => { onSuccess: (blob) => {
if (isTextPreview(contentType)) { if (isTextPreview(resolved.contentType)) {
blob.text().then(setTextContent); blob.text().then(setTextContent);
} else { } else {
// Re-wrap with the actual MIME type so browsers render PDFs/images inline const typedBlob = new Blob([blob], { type: resolved.contentType });
// instead of triggering a download (the HTTP response uses application/octet-stream).
const typedBlob = new Blob([blob], { type: contentType });
setBlobUrl(URL.createObjectURL(typedBlob)); setBlobUrl(URL.createObjectURL(typedBlob));
} }
}, },
@@ -98,7 +166,7 @@ export default function AttachmentPreview({
setImageZoom(1); setImageZoom(1);
previewMutation.mutate(); previewMutation.mutate();
} }
}, [open]); }, [open, resolved.contentHash]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -107,33 +175,41 @@ export default function AttachmentPreview({
}, [blobUrl]); }, [blobUrl]);
const handleDownload = () => { const handleDownload = () => {
download_attachment(accountId, envelopeId, contentHash, fileName); download_attachment(accountId, envelopeId, resolved.contentHash, resolved.fileName);
}; };
const { icon, color } = useMemo(() => getFileConfig(contentType), [contentType]); const { icon } = useMemo(() => getFileConfig(resolved.contentType), [resolved.contentType]);
const isImage = isImagePreview(contentType); const isImage = isImagePreview(resolved.contentType);
const isPdf = isPdfPreview(contentType); const isPdf = isPdfPreview(resolved.contentType);
const isText = isTextPreview(contentType); const isText = isTextPreview(resolved.contentType);
const showArrows = imageTotal > 1 && isImage;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className="w-[calc(100vw-2rem)] max-w-4xl h-[85vh] flex flex-col p-0 gap-0" className="w-screen h-screen max-w-none rounded-none p-0 gap-0 border-0 bg-slate-700/10"
hideClose
hideFullscreen
onInteractOutside={(e) => { onInteractOutside={(e) => {
// Don't close when interacting with the PDF viewer toolbar
if (isPdf) e.preventDefault(); if (isPdf) e.preventDefault();
}} }}
> >
{/* Toolbar */} {/* Toolbar — hidden for PDF (browser's native viewer has its own controls) */}
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0"> {!isPdf && (
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-2 bg-gradient-to-b from-black/70 to-transparent text-white">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className={color}>{icon}</div> {icon}
<span className="text-sm font-medium truncate max-w-[400px]"> <span className="text-sm font-medium truncate max-w-[400px]">
{fileName} {resolved.fileName}
</span> </span>
{imagePos >= 0 && imageTotal > 1 && (
<span className="text-xs text-white/60 ml-1">
{imagePos + 1} / {imageTotal}
</span>
)}
</div> </div>
<div className="flex items-center gap-1 pr-16"> <div className="flex items-center gap-1 pr-12">
{isImage && blobUrl && ( {isImage && blobUrl && (
<> <>
<Tooltip> <Tooltip>
@@ -141,7 +217,7 @@ export default function AttachmentPreview({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setImageZoom((z) => Math.min(z + 0.25, 3))} onClick={() => setImageZoom((z) => Math.min(z + 0.25, 3))}
> >
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" />
@@ -154,7 +230,7 @@ export default function AttachmentPreview({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setImageZoom((z) => Math.max(z - 0.25, 0.25))} onClick={() => setImageZoom((z) => Math.max(z - 0.25, 0.25))}
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" />
@@ -167,7 +243,7 @@ export default function AttachmentPreview({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setImageZoom(1)} onClick={() => setImageZoom(1)}
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
@@ -175,7 +251,7 @@ export default function AttachmentPreview({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t('attachment_preview.resetZoom')}</TooltipContent> <TooltipContent>{t('attachment_preview.resetZoom')}</TooltipContent>
</Tooltip> </Tooltip>
<Separator orientation="vertical" className="h-5 mx-1" /> <Separator orientation="vertical" className="h-5 mx-1 bg-white/20" />
</> </>
)} )}
<Tooltip> <Tooltip>
@@ -183,7 +259,7 @@ export default function AttachmentPreview({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8 text-white hover:bg-white/20"
onClick={handleDownload} onClick={handleDownload}
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -193,23 +269,59 @@ export default function AttachmentPreview({
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
)}
{/* Close button — positioned below browser PDF toolbar */}
<Button
variant="ghost"
size="icon"
className={isPdf
? 'absolute top-12 right-4 z-50 h-10 w-10 rounded-full text-white bg-black/50 hover:bg-black/70'
: 'absolute top-2 right-4 z-50 h-8 w-8 rounded-full text-white hover:bg-white/20'
}
onClick={() => onOpenChange(false)}
>
<X className={isPdf ? 'h-5 w-5' : 'h-4 w-4'} />
</Button>
{/* Navigation arrows */}
{showArrows && (
<>
<Button
variant="ghost"
size="icon"
disabled={imagePos <= 0}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 h-10 w-10 rounded-full text-white hover:bg-white/20 disabled:opacity-30 disabled:hover:bg-transparent"
onClick={goPrev}
>
<ChevronLeft className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={imagePos >= imageTotal - 1}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 h-10 w-10 rounded-full text-white hover:bg-white/20 disabled:opacity-30 disabled:hover:bg-transparent"
onClick={goNext}
>
<ChevronRight className="h-6 w-6" />
</Button>
</>
)}
{/* Preview body */} {/* Preview body */}
<div className="flex-1 min-h-0 bg-muted/30"> <div className="w-full h-full flex items-center justify-center">
{previewMutation.isPending ? ( {previewMutation.isPending ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<Skeleton className="w-64 h-4" /> <Skeleton className="w-64 h-4 bg-white/10" />
<Skeleton className="w-48 h-4" /> <Skeleton className="w-48 h-4 bg-white/10" />
<Skeleton className="w-56 h-4" /> <Skeleton className="w-56 h-4 bg-white/10" />
</div>
</div> </div>
) : isImage && blobUrl ? ( ) : isImage && blobUrl ? (
<div className="w-full h-full overflow-auto flex items-center justify-center"> <div className="w-full h-full overflow-auto flex items-center justify-center">
<img <img
src={blobUrl} src={blobUrl}
alt={fileName} alt={resolved.fileName}
className="max-w-full" className="max-w-full max-h-full object-contain"
style={{ style={{
transform: `scale(${imageZoom})`, transform: `scale(${imageZoom})`,
transformOrigin: 'center center', transformOrigin: 'center center',
@@ -220,28 +332,26 @@ export default function AttachmentPreview({
<iframe <iframe
src={blobUrl} src={blobUrl}
className="w-full h-full border-0" className="w-full h-full border-0"
title={fileName} title={resolved.fileName}
/> />
) : isText && textContent !== null ? ( ) : isText && textContent !== null ? (
<pre className="w-full h-full overflow-auto whitespace-pre-wrap text-sm font-mono p-6"> <pre className="w-full h-full overflow-auto whitespace-pre-wrap text-sm font-mono p-6 text-white/90">
{textContent} {textContent}
</pre> </pre>
) : !previewMutation.isPending ? ( ) : !previewMutation.isPending ? (
<div className="flex items-center justify-center h-full"> <div className="flex flex-col items-center gap-4 text-white/60">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<FileIcon className="h-16 w-16 opacity-30" /> <FileIcon className="h-16 w-16 opacity-30" />
<p className="text-sm">{t('attachment_preview.notAvailable')}</p> <p className="text-sm">{t('attachment_preview.notAvailable')}</p>
<p className="text-xs text-center max-w-md"> <p className="text-xs text-center max-w-md">
{t('attachment_preview.notAvailableDesc', { {t('attachment_preview.notAvailableDesc', {
type: contentType || 'unknown', type: resolved.contentType || 'unknown',
})} })}
</p> </p>
<Button variant="outline" size="sm" onClick={handleDownload}> <Button variant="outline" size="sm" onClick={handleDownload} className="text-white border-white/20 hover:bg-white/10">
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
{t('attachment.download')} {t('attachment.download')}
</Button> </Button>
</div> </div>
</div>
) : null} ) : null}
</div> </div>
</DialogContent> </DialogContent>
@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react'; import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -40,7 +40,7 @@ import { MailThreadDialog } from './thread-dialog';
import useMinimalAccountList from '@/hooks/use-minimal-account-list'; import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { NestedEmailDialog } from './nested-email-dialog'; import { NestedEmailDialog } from './nested-email-dialog';
import AttachmentPreview from './attachment-preview'; import AttachmentPreview, { type PreviewAttachment } from './attachment-preview';
import { EmailEnvelope } from '@/api'; import { EmailEnvelope } from '@/api';
@@ -124,7 +124,7 @@ export function MailMessageView({
const [threadOpen, setThreadOpen] = useState(false); const [threadOpen, setThreadOpen] = useState(false);
const [blockRemote, setBlockRemote] = useState(true); const [blockRemote, setBlockRemote] = useState(true);
const [hasRemoteContent, setHasRemoteContent] = useState(false); const [hasRemoteContent, setHasRemoteContent] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<{ content_hash: string; file_type: string; filename: string } | null>(null); const [previewAttachment, setPreviewAttachment] = useState<{ attachments: PreviewAttachment[]; index: number } | null>(null);
const toggleBlockRemote = () => { const toggleBlockRemote = () => {
setBlockRemote((prev) => !prev); setBlockRemote((prev) => !prev);
@@ -336,9 +336,12 @@ export function MailMessageView({
title={attachment.filename} title={attachment.filename}
onClick={() => onClick={() =>
setPreviewAttachment({ setPreviewAttachment({
content_hash: attachment.content_hash, attachments: nonInline.map((a) => ({
file_type: attachment.file_type, content_hash: a.content_hash,
filename: attachment.filename, file_type: a.file_type,
filename: a.filename,
})),
index: i,
}) })
} }
> >
@@ -370,16 +373,6 @@ export function MailMessageView({
<span className="text-gray-500 text-xs shrink-0"> <span className="text-gray-500 text-xs shrink-0">
{formatBytes(attachment.size)} {formatBytes(attachment.size)}
</span> </span>
<Eye
className="w-5 h-5 cursor-pointer hover:text-primary transition-colors"
onClick={() =>
setPreviewAttachment({
content_hash: attachment.content_hash,
file_type: attachment.file_type,
filename: attachment.filename,
})
}
/>
{downloadingAttachmentFileName === attachment.filename ? ( {downloadingAttachmentFileName === attachment.filename ? (
<Loader className="w-5 h-5 animate-spin" /> <Loader className="w-5 h-5 animate-spin" />
) : ( ) : (
@@ -459,15 +452,17 @@ export function MailMessageView({
fileName={nestedEmlFile?.filename || ''} fileName={nestedEmlFile?.filename || ''}
content_hash={nestedEmlFile?.content_hash} content_hash={nestedEmlFile?.content_hash}
/> />
{previewAttachment && ( {previewAttachment?.attachments?.[previewAttachment.index] && (
<AttachmentPreview <AttachmentPreview
open={!!previewAttachment} open={!!previewAttachment}
onOpenChange={(open) => !open && setPreviewAttachment(null)} onOpenChange={(open) => !open && setPreviewAttachment(null)}
accountId={envelope.account_id} accountId={envelope.account_id}
envelopeId={envelope.id} envelopeId={envelope.id}
contentHash={previewAttachment.content_hash} contentHash={previewAttachment.attachments[previewAttachment.index].content_hash}
contentType={previewAttachment.file_type} contentType={previewAttachment.attachments[previewAttachment.index].file_type}
fileName={previewAttachment.filename} fileName={previewAttachment.attachments[previewAttachment.index].filename}
attachments={previewAttachment.attachments}
attachmentIndex={previewAttachment.index}
/> />
)} )}
</div> </div>
+15 -20
View File
@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react'; import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -40,7 +40,7 @@ import { MailThreadDialog } from './thread-dialog';
import useMinimalAccountList from '@/hooks/use-minimal-account-list'; import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { NestedEmailDialog } from './nested-email-dialog'; import { NestedEmailDialog } from './nested-email-dialog';
import AttachmentPreview from '@/features/attachment/attachment-preview'; import AttachmentPreview, { type PreviewAttachment } from '@/features/attachment/attachment-preview';
interface MailMessageViewProps { interface MailMessageViewProps {
@@ -132,7 +132,7 @@ export function MailMessageView({
const [threadOpen, setThreadOpen] = useState(false); const [threadOpen, setThreadOpen] = useState(false);
const [blockRemote, setBlockRemote] = useState(true); const [blockRemote, setBlockRemote] = useState(true);
const [hasRemoteContent, setHasRemoteContent] = useState(false); const [hasRemoteContent, setHasRemoteContent] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<{ content_hash: string; file_type: string; filename: string } | null>(null); const [previewAttachment, setPreviewAttachment] = useState<{ attachments: PreviewAttachment[]; index: number } | null>(null);
const toggleBlockRemote = () => { const toggleBlockRemote = () => {
setBlockRemote((prev) => !prev); setBlockRemote((prev) => !prev);
@@ -344,9 +344,12 @@ export function MailMessageView({
title={attachment.filename} title={attachment.filename}
onClick={() => onClick={() =>
setPreviewAttachment({ setPreviewAttachment({
content_hash: attachment.content_hash, attachments: nonInline.map((a) => ({
file_type: attachment.file_type, content_hash: a.content_hash,
filename: attachment.filename, file_type: a.file_type,
filename: a.filename,
})),
index: i,
}) })
} }
> >
@@ -378,16 +381,6 @@ export function MailMessageView({
<span className="text-gray-500 text-xs shrink-0"> <span className="text-gray-500 text-xs shrink-0">
{formatBytes(attachment.size)} {formatBytes(attachment.size)}
</span> </span>
<Eye
className="w-5 h-5 cursor-pointer hover:text-primary transition-colors"
onClick={() =>
setPreviewAttachment({
content_hash: attachment.content_hash,
file_type: attachment.file_type,
filename: attachment.filename,
})
}
/>
{downloadingAttachmentFileName === attachment.filename ? ( {downloadingAttachmentFileName === attachment.filename ? (
<Loader className="w-5 h-5 animate-spin" /> <Loader className="w-5 h-5 animate-spin" />
) : ( ) : (
@@ -467,15 +460,17 @@ export function MailMessageView({
fileName={nestedEmlFile?.filename || ''} fileName={nestedEmlFile?.filename || ''}
content_hash={nestedEmlFile?.content_hash} content_hash={nestedEmlFile?.content_hash}
/> />
{previewAttachment && ( {previewAttachment?.attachments?.[previewAttachment.index] && (
<AttachmentPreview <AttachmentPreview
open={!!previewAttachment} open={!!previewAttachment}
onOpenChange={(open) => !open && setPreviewAttachment(null)} onOpenChange={(open) => !open && setPreviewAttachment(null)}
accountId={envelope.account_id} accountId={envelope.account_id}
envelopeId={envelope.id} envelopeId={envelope.id}
contentHash={previewAttachment.content_hash} contentHash={previewAttachment.attachments[previewAttachment.index].content_hash}
contentType={previewAttachment.file_type} contentType={previewAttachment.attachments[previewAttachment.index].file_type}
fileName={previewAttachment.filename} fileName={previewAttachment.attachments[previewAttachment.index].filename}
attachments={previewAttachment.attachments}
attachmentIndex={previewAttachment.index}
/> />
)} )}
</div> </div>