From 2b2b7b8298d63d0913134be28c08aa4f4086a001 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 09:01:04 +0100
Subject: [PATCH] fix: show every attachment in the composer, with type icons
and a total, failed and uploading files first
The attachment list test now expects every chip and a bounded, scrolling row instead of three chips and a +N counter.
---
.../composer-attachment-list-scroll.test.tsx | 41 ++++++----------
components/email/email-composer.tsx | 48 +++++++++----------
.../__tests__/attachment-order.test.ts | 18 +++++++
lib/compose/attachment-order.ts | 14 ++++++
locales/ar/common.json | 9 +++-
locales/ca/common.json | 9 +++-
locales/cs/common.json | 9 +++-
locales/da/common.json | 9 +++-
locales/de/common.json | 9 +++-
locales/en/common.json | 9 +++-
locales/es/common.json | 9 +++-
locales/et/common.json | 9 +++-
locales/fa/common.json | 9 +++-
locales/fr/common.json | 9 +++-
locales/he/common.json | 9 +++-
locales/hu/common.json | 9 +++-
locales/it/common.json | 9 +++-
locales/ja/common.json | 9 +++-
locales/ko/common.json | 9 +++-
locales/lv/common.json | 9 +++-
locales/mn/common.json | 9 +++-
locales/nb/common.json | 9 +++-
locales/nl/common.json | 9 +++-
locales/pl/common.json | 9 +++-
locales/pt/common.json | 9 +++-
locales/ro/common.json | 9 +++-
locales/ru/common.json | 9 +++-
locales/sk/common.json | 9 +++-
locales/th/common.json | 9 +++-
locales/tr/common.json | 9 +++-
locales/uk/common.json | 9 +++-
locales/vi/common.json | 9 +++-
locales/zh-TW/common.json | 9 +++-
locales/zh/common.json | 9 +++-
34 files changed, 309 insertions(+), 82 deletions(-)
create mode 100644 lib/compose/__tests__/attachment-order.test.ts
create mode 100644 lib/compose/attachment-order.ts
diff --git a/components/email/__tests__/composer-attachment-list-scroll.test.tsx b/components/email/__tests__/composer-attachment-list-scroll.test.tsx
index af1d5673f..04bc59023 100644
--- a/components/email/__tests__/composer-attachment-list-scroll.test.tsx
+++ b/components/email/__tests__/composer-attachment-list-scroll.test.tsx
@@ -218,21 +218,20 @@ const DRAFT = {
attachments: ATTACHMENTS,
};
-/** The wrapping flex row that holds every chip and the expand button. */
+/** The wrapping flex row that holds every chip. */
function chipRow() {
- return screen.getByText('invoice-0.pdf').closest('[class*="flex-wrap"]') as HTMLElement;
+ return screen.getByTestId('composer-attachments');
}
/**
* The attachment row is a shrink-0 sibling of the body's scroll container,
* so whatever height it takes is taken from the composer and from nothing
- * else. Collapsed that is three chips. Expanded it is one row per two
- * attachments, and with a few dozen files it grew past the bottom of the
- * composer: the send toolbar was pushed off-screen and no ancestor
- * scrolled, so neither the rest of the list nor Send could be reached.
- * Expanding therefore has to bound the row and scroll it itself.
+ * else. With a few dozen files it grew past the bottom of the composer: the
+ * send toolbar was pushed off-screen and no ancestor scrolled. It used to
+ * show three chips and "+N", which hid the fourth file's upload. Now every
+ * chip shows, wrapping, and the row is bounded and scrolls itself.
*/
-describe('expanded attachment list', () => {
+describe('attachment list', () => {
beforeEach(() => {
observers.length = 0;
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
@@ -242,39 +241,27 @@ describe('expanded attachment list', () => {
vi.unstubAllGlobals();
});
- it('does not scroll while collapsed', () => {
+ it('shows every attachment, with no counter to expand', () => {
render();
- expect(chipRow().className).not.toContain('overflow-y-auto');
+ expect(screen.getByText('invoice-3.pdf')).toBeInTheDocument();
+ expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument();
+ expect(screen.queryByText(`+${ATTACHMENTS.length - 3}`)).toBeNull();
});
- it('shows only the first three attachments and a counter for the rest', () => {
+ it('caps its height and scrolls itself', () => {
render();
- expect(screen.getByText('invoice-2.pdf')).toBeInTheDocument();
- expect(screen.queryByText('invoice-3.pdf')).not.toBeInTheDocument();
- expect(screen.getByText(`+${ATTACHMENTS.length - 3}`)).toBeInTheDocument();
- });
-
- it('caps its height and scrolls once expanded', () => {
- render();
-
- fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
-
const row = chipRow();
- expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument();
expect(row.className).toContain('overflow-y-auto');
// Bounded, or the row grows with the attachment count again and takes
// the send toolbar off the bottom of the composer with it.
expect(row.className).toContain('max-h-[');
});
- it('stops scrolling again when collapsed back', () => {
+ it('says how many files and how much they weigh', () => {
render();
- fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
- fireEvent.click(screen.getByText('show_less'));
-
- expect(chipRow().className).not.toContain('overflow-y-auto');
+ expect(screen.getByTestId('composer-attachments-summary')).toHaveTextContent('attachments_summary');
});
});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index e55816505..9f3c13640 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -68,6 +68,8 @@ import { isFilePreviewable } from "@/lib/file-preview";
import { isEditableEventTarget } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse";
+import { attachmentIcon } from "@/components/email/attachment-chips";
+import { attachmentsForDisplay } from "@/lib/compose/attachment-order";
import { showDraftSavedToast } from "@/lib/compose/draft-close-toast";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { containsEmbeddedSignature } from "@/components/email/signature-block";
@@ -1244,7 +1246,6 @@ export function EmailComposer({
const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close');
// Closing saves without asking; the question comes only when that save failed.
const [closeSaveFailed, setCloseSaveFailed] = useState(false);
- const [showAllAttachments, setShowAllAttachments] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState(null);
// What the checks before sending found (no subject, no text, placeholders,
// a missing attachment, open proofreading issues), asked about in one dialog.
@@ -5538,21 +5539,24 @@ export function EmailComposer({
{/* Attachments */}
{attachments.length > 0 && (