fix: drop the paperclip from messages with only inline images and keep list rows from growing when chips arrive

This commit is contained in:
Linus Rath
2026-10-31 04:45:01 +01:00
parent 219aef046a
commit 4470a4ba82
4 changed files with 67 additions and 7 deletions
+19 -4
View File
@@ -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 (
<View style={styles.row} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" testID="list-chips-placeholder">
<View style={[styles.chip, styles.placeholderChip]}>
<Text style={[styles.name, dyn.caption]}> </Text>
</View>
</View>
);
}
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,
+29
View File
@@ -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\) \{/);
});
});
+11
View File
@@ -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.
+8 -3
View File
@@ -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 && (
<Forward size={componentSizes.statusIcon} color={c.textMuted} />
)}
{item.hasAttachment && (
{showsPaperclip(item.hasAttachment, attachments) && (
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
)}
</View>
@@ -312,7 +316,8 @@ const EmailRow = React.memo(function EmailRow({
{onOpenAttachment && (
<ListAttachmentChips
email={item}
load={loadAttachments}
attachments={attachments}
pending={!!loadAttachments && item.hasAttachment && !item.attachments}
onOpen={onOpenAttachment}
disabled={selectionMode}
/>