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 && (