fix: hide the paperclip in the list when a message only has inline images

This commit is contained in:
Linus Rath
2026-10-11 17:57:04 +02:00
parent face095a83
commit 89d8bcc453
3 changed files with 75 additions and 6 deletions
@@ -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<Email>) => ({ 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();
});
});
+12
View File
@@ -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
+8 -4
View File
@@ -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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showPaperclip && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -669,7 +670,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
{showPaperclip && (
<StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
@@ -804,7 +805,10 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showAvatarsInJunk = useSettingsStore((state) => 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