diff --git a/src/components/email/ListAttachmentChips.tsx b/src/components/email/ListAttachmentChips.tsx index 51f68c9a..493a5940 100644 --- a/src/components/email/ListAttachmentChips.tsx +++ b/src/components/email/ListAttachmentChips.tsx @@ -27,7 +27,7 @@ const MAX_CHIPS = 2; * The row's attachment parts: the ones the email already carries (a message * opened before), else loaded lazily when it has a paperclip. */ -function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined { +export function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined { const [loaded, setLoaded] = React.useState<{ key: string; attachments: Attachment[] } | null>(null); const needsLoad = !!load && !!email.hasAttachment && !email.attachments; // Rows of a list spanning accounts can share an id (#1082). @@ -47,7 +47,10 @@ function useListAttachments(email: Email, load?: LoadListAttachments): Attachmen interface ChipsProps { email: Email; - load?: LoadListAttachments; + /** The row's parts (useListAttachments); undefined while they load. */ + attachments: Attachment[] | undefined; + /** The parts are being loaded: hold the chips' line so the row doesn't grow. */ + pending?: boolean; onOpen: (email: Email, attachment: Attachment) => void; /** Selection mode: taps belong to the row. */ disabled?: boolean; @@ -59,13 +62,23 @@ interface ChipsProps { * that file directly. */ export const ListAttachmentChips = React.memo(function ListAttachmentChips({ - email, load, onOpen, disabled, + email, attachments, pending, onOpen, disabled, }: ChipsProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const dyn = useTypography(); - const attachments = useListAttachments(email, load); const real = React.useMemo(() => realAttachments(attachments), [attachments]); + // The line the chips will take, held while they load (webmail 7b25d3a95): + // the row grew under the finger when they arrived. + if (pending && attachments === undefined) { + return ( + + + + + + ); + } if (real.length === 0) return null; const shown = real.slice(0, MAX_CHIPS); @@ -214,6 +227,8 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.surface, }, chipPressed: { backgroundColor: c.surfaceHover }, + // Empty space of a chip's height; a failed load leaves no grey stub behind. + placeholderChip: { width: 96, opacity: 0 }, name: { ...typography.caption, color: c.textSecondary, flexShrink: 1 }, more: { paddingHorizontal: 6, diff --git a/src/lib/__tests__/list-paperclip.test.ts b/src/lib/__tests__/list-paperclip.test.ts new file mode 100644 index 00000000..59324825 --- /dev/null +++ b/src/lib/__tests__/list-paperclip.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { showsPaperclip } from '../list-attachments'; +import type { Attachment } from '../../api/types'; + +// A message with nothing but inline images (a signature logo) kept its +// paperclip in the list, and the row grew when its chips arrived (webmail +// attachment-chips.tsx, 7b25d3a95). + +const inline = { blobId: 'b1', type: 'image/png', name: 'logo.png', disposition: 'inline', cid: 'logo' } as Attachment; +const file = { blobId: 'b2', type: 'application/pdf', name: 'invoice.pdf' } as Attachment; + +describe('list row paperclip and chips', () => { + it("follows the server's flag until the parts are in, then only real attachments", () => { + expect(showsPaperclip(true, undefined)).toBe(true); + expect(showsPaperclip(true, [inline])).toBe(false); + expect(showsPaperclip(true, [inline, file])).toBe(true); + expect(showsPaperclip(false, undefined)).toBe(false); + }); + + it('is wired into the row, which holds the chips line while they load', () => { + const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/showsPaperclip\(item\.hasAttachment, attachments\)/); + expect(list).toMatch(/pending=\{!!loadAttachments && item\.hasAttachment && !item\.attachments\}/); + const chips = fs.readFileSync(path.resolve(__dirname, '../../components/email/ListAttachmentChips.tsx'), 'utf8'); + expect(chips).toMatch(/if \(pending && attachments === undefined\) \{/); + }); +}); diff --git a/src/lib/list-attachments.ts b/src/lib/list-attachments.ts index aa24b102..f33f3c4f 100644 --- a/src/lib/list-attachments.ts +++ b/src/lib/list-attachments.ts @@ -23,6 +23,17 @@ export function realAttachments(attachments?: readonly Attachment[] | null): Att return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name && !isReportPart(a.type)); } +/** + * Whether a list row shows the paperclip: the server's `hasAttachment` until + * the row's parts are loaded, then only when a real attachment is among them, + * so a message with nothing but inline images loses it (webmail + * components/email/attachment-chips.tsx). + */ +export function showsPaperclip(hasAttachment: boolean, attachments: readonly Attachment[] | undefined): boolean { + if (attachments === undefined) return hasAttachment; + return realAttachments(attachments).length > 0; +} + /** * Long names are unreadable cut at the tail; the extension is the most * identifying part, so keep it and elide the middle. diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 0411ea1d..b5c66884 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -24,8 +24,9 @@ import { MoveSheet } from '../components/MoveSheet'; import { TagSheet } from '../components/TagSheet'; import { OfflineBanner } from '../components/OfflineBanner'; import { - ListAttachmentChips, ListAttachmentOpener, useListRowAttachments, + ListAttachmentChips, ListAttachmentOpener, useListAttachments, useListRowAttachments, } from '../components/email/ListAttachmentChips'; +import { showsPaperclip } from '../lib/list-attachments'; import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip'; import { useSenderWarning } from '../components/email/use-sender-warning'; import type { LoadListAttachments } from '../lib/list-attachments'; @@ -193,6 +194,9 @@ const EmailRow = React.memo(function EmailRow({ const handlePress = React.useCallback(() => onPress(key), [onPress, key]); const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]); const verificationCode = useListVerificationCode(item); + // The row's parts, loaded once it is on screen: the chips show them and + // the paperclip goes when they are only inline images. + const attachments = useListAttachments(item, onOpenAttachment ? loadAttachments : undefined); // Sent and Drafts rows show the recipient: the check says nothing about them. const senderWarning = useSenderWarning(showRecipient ? undefined : item); @@ -272,7 +276,7 @@ const EmailRow = React.memo(function EmailRow({ {forwarded && ( )} - {item.hasAttachment && ( + {showsPaperclip(item.hasAttachment, attachments) && ( )} @@ -312,7 +316,8 @@ const EmailRow = React.memo(function EmailRow({ {onOpenAttachment && (