From 89d8bcc4531da2da0507d5881931d347ed957520 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:57:04 +0200 Subject: [PATCH] fix: hide the paperclip in the list when a message only has inline images --- .../email/__tests__/attachment-chips.test.tsx | 57 ++++++++++++++++++- components/email/attachment-chips.tsx | 12 ++++ components/email/thread-list-item.tsx | 12 ++-- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/components/email/__tests__/attachment-chips.test.tsx b/components/email/__tests__/attachment-chips.test.tsx index 2553c18df..1ed87c478 100644 --- a/components/email/__tests__/attachment-chips.test.tsx +++ b/components/email/__tests__/attachment-chips.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, act } from "@testing-library/react"; -import { realAttachments, ListAttachmentChips } from "../attachment-chips"; +import { render, screen, act, renderHook } from "@testing-library/react"; +import { realAttachments, ListAttachmentChips, useListHasAttachment } from "../attachment-chips"; import type { Attachment, Email } from "@/lib/jmap/types"; import type { LoadListAttachments } from "@/lib/list-attachments"; @@ -118,3 +118,56 @@ describe("ListAttachmentChips", () => { expect(load).toHaveBeenCalledTimes(2); }); }); + +// Stalwart flags a message whose only parts are inline images (a logo in a +// notification mail) with hasAttachment; the viewer then lists nothing. +describe("useListHasAttachment", () => { + const row = (over: Partial) => ({ id: "m1", hasAttachment: true, ...over }) as Email; + const deferred = () => { + let deliver!: (a: Attachment[]) => void; + const load = vi.fn((_email: Email, onLoad: (a: Attachment[]) => void) => { deliver = onLoad; return () => {}; }); + return { load, deliver: (a: Attachment[]) => deliver(a) }; + }; + + it("trusts the server flag until the parts arrive", () => { + const { load } = deferred(); + const { result } = renderHook(() => useListHasAttachment(row({}), load)); + expect(result.current).toBe(true); + }); + + it("drops the paperclip when every part is an inline image", () => { + const { load, deliver } = deferred(); + const { result } = renderHook(() => useListHasAttachment(row({}), load)); + act(() => deliver([ + att({ name: "logo.png", type: "image/png", disposition: "inline", cid: "logo" }), + att({ partId: "2", blobId: "b2", name: "github.png", type: "image/png", cid: "gh" }), + ])); + expect(result.current).toBe(false); + }); + + it("keeps the paperclip when a real attachment is among the parts", () => { + const { load, deliver } = deferred(); + const { result } = renderHook(() => useListHasAttachment(row({}), load)); + act(() => deliver([ + att({ name: "logo.png", type: "image/png", disposition: "inline", cid: "logo" }), + att({ partId: "2", blobId: "b2", name: "invoice.pdf" }), + ])); + expect(result.current).toBe(true); + }); + + it("uses parts the email already carries", () => { + const load = vi.fn(() => () => {}); + const { result } = renderHook(() => useListHasAttachment( + row({ attachments: [att({ name: "logo.png", type: "image/png", disposition: "inline" })] }), load, + )); + expect(result.current).toBe(false); + expect(load).not.toHaveBeenCalled(); + }); + + it("shows nothing for a message the server did not flag", () => { + const load = vi.fn(() => () => {}); + const { result } = renderHook(() => useListHasAttachment(row({ hasAttachment: false }), load)); + expect(result.current).toBe(false); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/attachment-chips.tsx b/components/email/attachment-chips.tsx index cc67714c7..9aa2a6f8b 100644 --- a/components/email/attachment-chips.tsx +++ b/components/email/attachment-chips.tsx @@ -134,6 +134,18 @@ export function useListAttachments(email: Email, load?: LoadListAttachments): At return loaded?.id === email.id ? loaded.attachments : undefined; } +/** + * Whether a list row shows the paperclip. Stalwart sets `hasAttachment` for + * inline images too (a logo in a notification mail), which the viewer never + * lists, so once the parts are in the flag only counts if one of them is a + * real attachment. Until then the server's flag stands. + */ +export function useListHasAttachment(email: Email, load?: LoadListAttachments): boolean { + const attachments = useListAttachments(email, load); + if (!email.hasAttachment) return false; + return attachments === undefined || realAttachments(attachments).length > 0; +} + /** * Whether a list row will draw a chip row for this email, counting the room * it holds while the parts load. Lets the list estimate a row's height diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 5a2a9d638..d2a769d81 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -4,7 +4,7 @@ import React, { useCallback } from "react"; import { cleanPreview, formatDate, formatDateTime } from "@/lib/utils"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; -import { ListAttachmentChips } from "./attachment-chips"; +import { ListAttachmentChips, useListHasAttachment } from "./attachment-chips"; import { VerificationCodeChip, useListVerificationCode } from "./verification-code-chip"; import type { Attachment } from "@/lib/jmap/types"; import type { LoadListAttachments } from "@/lib/list-attachments"; @@ -281,6 +281,7 @@ const SingleEmailItem = React.forwardRef( const isPinned = email.keywords?.['$pinned'] === true; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; + const showPaperclip = useListHasAttachment(email, loadAttachments); const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); // In Sent/Drafts folders, show recipient instead of sender (which is always // "me"). In aggregate role-views the selected mailbox is virtual → fall back @@ -598,7 +599,7 @@ const SingleEmailItem = React.forwardRef( )} - {email.hasAttachment && } + {showPaperclip && } {showSourceFolder && } {scheduledSendLabel ? ( ( )} - {email.hasAttachment && ( + {showPaperclip && ( )} @@ -804,7 +805,10 @@ export const ThreadListItem = React.forwardRef state.showAvatarsInJunk); const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); const isMobile = useUIStore((state) => state.isMobile); - const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; + const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAnswered, hasForwarded, emailCount } = thread; + // Only the latest email's parts are loaded; an older one with the flag keeps the paperclip. + const latestHasAttachment = useListHasAttachment(latestEmail, loadAttachments); + const hasAttachment = latestHasAttachment || thread.emails.some((e) => e !== latestEmail && e.hasAttachment); // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; // Mountain View leads its rows with a checkbox and a clickable star