From 29b33eb46cdd007c18f2c4a462844f317ae5b3be Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:32:30 +0200 Subject: [PATCH] fix: show attachment names without directional override characters --- components/email/attachment-chips.tsx | 5 +++-- components/email/email-viewer.tsx | 5 ++++- lib/__tests__/bidi.test.ts | 15 +++++++++++++++ lib/bidi.ts | 12 ++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/bidi.test.ts create mode 100644 lib/bidi.ts diff --git a/components/email/attachment-chips.tsx b/components/email/attachment-chips.tsx index 9aa2a6f8b..4ced28825 100644 --- a/components/email/attachment-chips.tsx +++ b/components/email/attachment-chips.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { FileText, FileSpreadsheet, FileImage, FileArchive, File as FileIcon } from "@/components/icons"; import { cn } from "@/lib/utils"; +import { stripBidiControls } from "@/lib/bidi"; import type { Attachment, Email } from "@/lib/jmap/types"; import type { LoadListAttachments } from "@/lib/list-attachments"; @@ -78,11 +79,11 @@ export function AttachmentChips({ attachments, onOpen, max = 2, leading, classNa // The row itself opens the message; a chip must not do both. onClick={(e) => { e.stopPropagation(); onOpen(a); }} onDoubleClick={(e) => e.stopPropagation()} - title={a.name} + title={stripBidiControls(a.name ?? "")} className={cn(CHIP_BOX_CLASS, "max-w-[12rem] bg-background/60 text-foreground/80 hover:bg-muted hover:text-foreground")} > - {shortName(a.name ?? "")} + {shortName(stripBidiControls(a.name ?? ""))} ); })} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7ed5fd726..d179d93eb 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -11,6 +11,7 @@ import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collaps import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { hasFullBleedCanvas } from "@/lib/email-full-bleed"; +import { stripBidiControls } from "@/lib/bidi"; import { withBasePath } from "@/lib/browser-navigation"; import { buildContactsPath, buildMailPath } from "@/lib/deep-links"; import { useCopyLink } from "@/hooks/use-copy-link"; @@ -269,7 +270,9 @@ const MIME_TYPE_LABELS: Record = { }; const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: string): string => { - if (name) return name; + // Without directional overrides, which would show `report‮fdp.exe` + // as `reportexe.pdf` (the download name is cleaned separately). + if (name) return stripBidiControls(name); if (mimeType) { const label = MIME_TYPE_LABELS[mimeType.toLowerCase()]; if (label) return label; diff --git a/lib/__tests__/bidi.test.ts b/lib/__tests__/bidi.test.ts new file mode 100644 index 000000000..25837fe89 --- /dev/null +++ b/lib/__tests__/bidi.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { stripBidiControls } from '@/lib/bidi'; + +describe('stripBidiControls', () => { + it('removes the characters that reorder what the reader sees', () => { + // An RTL override makes `report‮fdp.exe` read as `reportexe.pdf`. + expect(stripBidiControls('report‮fdp.exe')).toBe('reportfdp.exe'); + expect(stripBidiControls('⁦a⁩‎b‏‪‫‬‭')).toBe('ab'); + }); + + it('leaves ordinary text, including right-to-left scripts, alone', () => { + expect(stripBidiControls('Rechnung März.pdf')).toBe('Rechnung März.pdf'); + expect(stripBidiControls('تقرير.pdf')).toBe('تقرير.pdf'); + }); +}); diff --git a/lib/bidi.ts b/lib/bidi.ts new file mode 100644 index 000000000..1aa5ead27 --- /dev/null +++ b/lib/bidi.ts @@ -0,0 +1,12 @@ +/** + * Unicode directional formatting characters: the explicit embeddings, + * overrides and isolates (U+202A-202E, U+2066-2069) and the LRM/RLM marks. + * In a sender-controlled name they reorder what the reader sees, so + * `report‮fdp.exe` shows as `reportexe.pdf`. + */ +const BIDI_CONTROLS = /[‎‏‪-‮⁦-⁩]/g; + +/** `text` without directional formatting characters, for display. */ +export function stripBidiControls(text: string): string { + return text.replace(BIDI_CONTROLS, ''); +}