diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index 301257065..679b5aee8 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -297,6 +297,40 @@ describe('reader-actions', () => { }); }); +describe('attachment-actions', () => { + const withAttachment = () => textMail('See attached', { + hasAttachment: true, + attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }], + } as Partial); + + it('puts the frames next to the chip, not in its hover overlay', () => { + offerSlots('p1', ['attachment-actions']); + viewer(withAttachment()); + + // Desktop chip and phone chip are both in the DOM. + const frames = screen.getAllByTestId('slot-attachment-actions'); + expect(frames.length).toBeGreaterThan(0); + for (const frame of frames) { + // Nothing between the frame and the page may hide it until hover. + for (let el: HTMLElement | null = frame; el; el = el.parentElement) { + expect(el.className).not.toMatch(/\bopacity-0\b/); + } + expect(frame.closest('[data-attachment-name]')).toBeNull(); + expect(slotProps(frame)).toEqual({ + attachment: { name: 'invoice.pdf', type: 'application/pdf', size: 1234, blobId: 'blob-pdf', emailId: 'e1' }, + }); + } + }); + + it('keeps the invisible hover overlay from taking taps', () => { + viewer(withAttachment()); + const overlays = [...document.querySelectorAll('[data-attachment-name="invoice.pdf"] div')] + .filter((d) => /\bopacity-0\b/.test(d.className)); + expect(overlays.length).toBeGreaterThan(0); + for (const overlay of overlays) expect(overlay.className).toMatch(/\bpointer-events-none\b/); + }); +}); + describe('attachment-menu', () => { const withAttachments = () => textMail('See attached', { hasAttachment: true, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index a43e1c776..650a86e6e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -123,7 +123,7 @@ import { ConversationEmailBody, useConversationExternalContent, useMarkReadWhenS import type { TnefAttachment } from "@/lib/tnef"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginActions } from "@/components/plugins/plugin-actions"; -import { AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; +import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; import { RulesPanel, useRulesAvailability } from "./rules-menu"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -4702,7 +4702,7 @@ export function EmailViewer({ const thumbUrl = imageThumbUrls[attachment.id]; return ( - {(dragProps) => ( + {(dragProps) => (<>
)} -
- )} + + )}
); })} @@ -4799,7 +4787,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - {(dragProps) => ( + {(dragProps) => (<>
{formatFileSize(attachment.size)} -
+
)} -
- )} + + )} ); })} @@ -5560,7 +5536,7 @@ export function EmailViewer({ const thumbUrl = imageThumbUrls[attachment.id]; return ( - {(dragProps) => ( + {(dragProps) => (<>
)} -
- )} + + )}
); })} @@ -5662,7 +5626,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - {(dragProps) => ( + {(dragProps) => (<>
{formatFileSize(attachment.size)} -
+
)} -
- )} + + )} ); })} @@ -5734,7 +5686,7 @@ export function EmailViewer({ const thumbUrl = imageThumbUrls[attachment.id]; return ( - {(dragProps) => ( + {(dragProps) => (<>
)} -
- )} + + )}
); })} @@ -5831,7 +5771,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - {(dragProps) => ( + {(dragProps) => (<>
-
+
)} -
- )} + + )} ); })} diff --git a/components/email/reader-plugin-surfaces.tsx b/components/email/reader-plugin-surfaces.tsx index 7a7d87f8b..f6f3cde0f 100644 --- a/components/email/reader-plugin-surfaces.tsx +++ b/components/email/reader-plugin-surfaces.tsx @@ -16,6 +16,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { runAction } from "@/lib/plugin-sandbox/actions"; import { QUOTE_TOGGLE_ATTR } from "@/lib/quote-collapse"; import { cn } from "@/lib/utils"; +import type { AttachmentInfo } from "@/lib/plugin-types"; // ─── attachment-menu ───────────────────────────────────────── @@ -82,6 +83,41 @@ export function AttachmentPluginMenu({ ); } +// ─── attachment-actions ────────────────────────────────────── + +/** + * The `attachment-actions` frames of one attachment, drawn next to its chip. + * They used to sit in the chip's hover overlay, where a wide frame was cut + * off and covered the name, and on phones (no hover) lay invisible over the + * chip and swallowed taps meant for its menu button. + */ +export function AttachmentPluginFrames({ + emailId, + attachment, + className, +}: { + emailId: string | undefined; + attachment: AttachmentMenuTarget; + className?: string; +}) { + const extraProps = useMemo(() => ({ + attachment: { + name: attachment.name || "", + type: attachment.type, + size: attachment.size, + blobId: attachment.blobId, + emailId, + } satisfies AttachmentInfo, + }), [emailId, attachment.name, attachment.type, attachment.size, attachment.blobId]); + return ( + + ); +} + // ─── Entries in a phone slide-in panel ─────────────────────── /**