diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index bbdc0a76..dca760a0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2194,9 +2194,10 @@ export function EmailViewer({ word's min-content width so columns are not collapsed to a single char. */ td, th { overflow-wrap: break-word; } pre { white-space: pre-wrap; word-wrap: break-word; } + [dir="auto"] { unicode-bidi: plaintext; } ${wordHtmlCSS} ${darkModeCSS} -${effectiveEmailContent.html}`; + ${effectiveEmailContent.html}`; }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]); // Unblocking external content is handled by rebuilding the iframe srcDoc: diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 446865b6..6cf5f32a 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -8,7 +8,9 @@ import Heading from "@tiptap/extension-heading"; import Underline from "@tiptap/extension-underline"; import Link from "@tiptap/extension-link"; import TextAlign from "@tiptap/extension-text-align"; -import { TextDirection } from "@/components/email/text-direction"; +// NOTE: @tiptap/core v3 ships a built-in TextDirection extension (auto-loaded +// via enableCoreExtensions). We no longer ship a custom one; configure it via +// the `textDirection` editor option below. import { TextStyle } from "@tiptap/extension-text-style"; import Color from "@tiptap/extension-color"; import { ResizableImage } from "@/components/email/resizable-image"; @@ -32,7 +34,8 @@ import { AlignLeft, AlignCenter, AlignRight, - ArrowLeftRight, + ArrowRightToLine as LtrIcon, + ArrowLeftToLine as RtlIcon, Link as LinkIcon, Undo, Redo, @@ -255,8 +258,12 @@ export function RichTextEditor({ // rich/branded signatures keep their inline styling in the editor and // in the sent mail (see signature-block.ts). SignatureBlock, - TextDirection, ], + // Enable @tiptap/core v3's built-in TextDirection extension with "auto" + // default. Each block auto-detects direction from its first strong + // character (RTL for Farsi/Arabic/Hebrew, LTR for Latin). The toolbar + // button can still pin an explicit ltr/rtl per block. + textDirection: "auto", content, editorProps: { attributes: { @@ -530,16 +537,22 @@ export function RichTextEditor({ {rtlEditingSupport && ( { const cur = - editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir; - editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run(); + editor.getAttributes("paragraph").dir || + editor.getAttributes("heading").dir || + "auto"; + const next = cur === "rtl" ? "ltr" : "rtl"; + editor.chain().focus().setTextDirection(next).run(); }} title={tToolbar("text_direction")} > - + {(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl" + ? + : } )} diff --git a/components/email/text-direction.ts b/components/email/text-direction.ts deleted file mode 100644 index 6665c373..00000000 --- a/components/email/text-direction.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Extension } from "@tiptap/core"; - -export type TextDir = "ltr" | "rtl"; - -declare module "@tiptap/core" { - interface Commands { - textDirection: { - setTextDirection: (dir: TextDir) => ReturnType; - unsetTextDirection: () => ReturnType; - }; - } -} - -/** - * Adds a `dir` attribute to block nodes so the composer can mark individual - * paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). - * - * The default is `"auto"`: each block detects its own direction from its first - * strong character, so a paragraph typed in English renders LTR and one typed - * in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins - * an explicit `ltr`/`rtl` when you want to override the auto-detection, and the - * attribute round-trips to HTML so the direction is preserved in the sent mail. - */ -export const TextDirection = Extension.create({ - name: "textDirection", - - addOptions() { - return { types: ["paragraph", "heading", "blockquote", "listItem"] }; - }, - - addGlobalAttributes() { - return [ - { - types: this.options.types, - attributes: { - dir: { - default: "auto", - parseHTML: (element) => element.getAttribute("dir") || "auto", - renderHTML: (attributes) => - attributes.dir ? { dir: attributes.dir } : { dir: "auto" }, - }, - }, - }, - ]; - }, - - addCommands() { - return { - setTextDirection: - (dir) => - ({ commands }) => - this.options.types.every((type: string) => - commands.updateAttributes(type, { dir }), - ), - unsetTextDirection: - () => - ({ commands }) => - this.options.types.every((type: string) => - commands.resetAttributes(type, "dir"), - ), - }; - }, -}); diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index 06ac7faa..fd6216a1 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -12,7 +12,7 @@ export const EMAIL_SANITIZE_CONFIG = { // components/email/quoted-html.ts). Explicitly whitelisted despite // ALLOW_DATA_ATTR:false so the viewer can detect and collapse the quoted // original (lib/quote-collapse.ts); it's inert otherwise. - ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color', 'data-quoted-html'], + ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color', 'dir', 'data-quoted-html'], ALLOW_DATA_ATTR: false, FORCE_BODY: true, // Allow blob: URIs so authenticated inline images (CID) are not stripped. diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 2b628a57..dfe61fac 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -486,7 +486,7 @@ const DEFAULT_SETTINGS = { defaultReplyMode: 'reply' as ReplyMode, autoSelectReplyIdentity: false, plainTextMode: false, - rtlEditingSupport: false, + rtlEditingSupport: true, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, sendDelaySeconds: 0 as SendDelaySeconds, signaturePosition: 'below_quote' as SignaturePosition, @@ -1023,64 +1023,64 @@ export const useSettingsStore = create()( */ export function migrateSettings(persisted: unknown, version: number): SettingsState { const state = persisted as Record; - if (version < 2 && state.listDensity) { - state.density = state.listDensity; - delete state.listDensity; - } - if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) { - state.sendDelaySeconds = 0; - } - if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') { - state.protocolOpenMode = state.protocolMailtoOpenMode; - } - delete state.protocolMailtoOpenMode; - // v4: `dateFormat` was repurposed from 'regional'|'iso'|'custom' to - // 'smart'|'relative'|'full'. The old setting was never read anywhere, - // so every persisted value maps to the new default. - if (version < 4) { - state.dateFormat = 'smart'; - } - // v5: allMailFolderIds went from a global `string[] | null` to a - // per-account `Record`. The legacy global list - // can't be attributed to a specific account here (the active account - // isn't known at migrate time), so it's dropped - each account starts - // "not configured" (defaults to all no-role folders). - if (version < 5 || !isPlainRecord(state.allMailFolderIds)) { - state.allMailFolderIds = {}; - } - // "All accounts" was reworked into the account-bounded "Unified - // Mailbox". The standalone __all_mail__ view (`enableAllMailView`) was - // folded into the unified "All mail" entry (`enableCrossAllView`), and a - // `unifiedCrossAccount` toggle now governs whether the views span every - // logged-in account. Existing users keep their current behaviour: - // - if any cross view was on, they were already cross-account -> keep it on - // - else if only standalone All Mail was on, enable the account-bounded - // unified "All mail" entry (folder selection carries over via allMailFolderIds) - // Guarded at <7 (not <6) so users who stopped at main's interim v6 - // identity-map bump - which shipped without this rework - still receive it. - if (version < 7) { - const hadCross = !!(state.enableCrossUnreadView || state.enableCrossStarredView || state.enableCrossAllView); - if (hadCross) { - state.unifiedCrossAccount = true; - } else if (state.enableAllMailView) { - state.enableUnifiedMailbox = true; - state.enableCrossAllView = true; - state.unifiedCrossAccount = false; - } - delete state.enableAllMailView; - if (typeof state.unifiedCrossAccount !== 'boolean') state.unifiedCrossAccount = false; - // The reworked unified mailbox spans the account's own folders plus its - // shared/group folders, so enable shared inclusion for every migrated - // configuration (matches the new-install default). - state.includeGroupInUnified = true; - } - // Per-account default-identity map (issue #507). Coerce any - // missing/legacy value to an empty record. Guarded at <6 so users who - // already received it via main's v6 bump keep their populated map. - if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) { - state.preferredIdentityIds = {}; - } - return state as unknown as SettingsState; + if (version < 2 && state.listDensity) { + state.density = state.listDensity; + delete state.listDensity; + } + if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) { + state.sendDelaySeconds = 0; + } + if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') { + state.protocolOpenMode = state.protocolMailtoOpenMode; + } + delete state.protocolMailtoOpenMode; + // v4: `dateFormat` was repurposed from 'regional'|'iso'|'custom' to + // 'smart'|'relative'|'full'. The old setting was never read anywhere, + // so every persisted value maps to the new default. + if (version < 4) { + state.dateFormat = 'smart'; + } + // v5: allMailFolderIds went from a global `string[] | null` to a + // per-account `Record`. The legacy global list + // can't be attributed to a specific account here (the active account + // isn't known at migrate time), so it's dropped - each account starts + // "not configured" (defaults to all no-role folders). + if (version < 5 || !isPlainRecord(state.allMailFolderIds)) { + state.allMailFolderIds = {}; + } + // "All accounts" was reworked into the account-bounded "Unified + // Mailbox". The standalone __all_mail__ view (`enableAllMailView`) was + // folded into the unified "All mail" entry (`enableCrossAllView`), and a + // `unifiedCrossAccount` toggle now governs whether the views span every + // logged-in account. Existing users keep their current behaviour: + // - if any cross view was on, they were already cross-account -> keep it on + // - else if only standalone All Mail was on, enable the account-bounded + // unified "All mail" entry (folder selection carries over via allMailFolderIds) + // Guarded at <7 (not <6) so users who stopped at main's interim v6 + // identity-map bump - which shipped without this rework - still receive it. + if (version < 7) { + const hadCross = !!(state.enableCrossUnreadView || state.enableCrossStarredView || state.enableCrossAllView); + if (hadCross) { + state.unifiedCrossAccount = true; + } else if (state.enableAllMailView) { + state.enableUnifiedMailbox = true; + state.enableCrossAllView = true; + state.unifiedCrossAccount = false; + } + delete state.enableAllMailView; + if (typeof state.unifiedCrossAccount !== 'boolean') state.unifiedCrossAccount = false; + // The reworked unified mailbox spans the account's own folders plus its + // shared/group folders, so enable shared inclusion for every migrated + // configuration (matches the new-install default). + state.includeGroupInUnified = true; + } + // Per-account default-identity map (issue #507). Coerce any + // missing/legacy value to an empty record. Guarded at <6 so users who + // already received it via main's v6 bump keep their populated map. + if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) { + state.preferredIdentityIds = {}; + } + return state as unknown as SettingsState; } // Helper functions to apply settings to DOM