Load the composer, previews, dialogs and other sidebars on demand

The main chunk carried everything the mail view might open: the file
preview and its Markdown renderer, the composer and its editor, the contact
editor, the filter and share dialogs, and the calendar, contacts and files
sidebars. Each is now loaded when first shown. The composer is also
fetched when the browser is idle after startup, so the first Compose does
not wait on the network.

Import the notification helpers statically where they already were: the
dynamic imports beside those static ones split nothing.
This commit is contained in:
2026-09-16 09:48:04 -07:00
parent e3cd56314b
commit 6139031689
9 changed files with 76 additions and 41 deletions
+3 -5
View File
@@ -16,7 +16,7 @@ import { LoginPage } from "@/views/Login";
import { AppShell } from "@/views/AppShell"; import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView"; import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock"; import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify/notify"; import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
import { publishWorkerFacts } from "@/lib/sw/swFacts"; import { publishWorkerFacts } from "@/lib/sw/swFacts";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings"; import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync"; import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
@@ -260,10 +260,8 @@ function AuthedApp() {
}); });
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
useEffect(() => { useEffect(() => {
void import("@/lib/notify/notify").then((m) => { setBaseTitle(appName);
m.setBaseTitle(appName);
setUnreadBadge(inboxUnread); setUnreadBadge(inboxUnread);
});
}, [inboxUnread, appName]); }, [inboxUnread, appName]);
/* /*
@@ -284,7 +282,7 @@ function AuthedApp() {
// Request notification permission lazily when enabled // Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications); const notif = useSettings((s) => s.settings.desktopNotifications);
useEffect(() => { useEffect(() => {
if (notif) void import("@/lib/notify/notify").then((m) => m.requestNotificationPermission()); if (notif) void requestNotificationPermission();
}, [notif]); }, [notif]);
// Nothing worth painting until the account's settings are in force; see the // Nothing worth painting until the account's settings are in force; see the
+1 -1
View File
@@ -28,6 +28,7 @@ import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
import { type ListQuery, type MailState } from "./types"; import { type ListQuery, type MailState } from "./types";
import { playNewMailSound, showNotification } from "@/lib/notify/notify";
/* /*
* `@/store/mail` stays the one public entry. The split below is about file * `@/store/mail` stays the one public entry. The split below is about file
@@ -1206,7 +1207,6 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
const emails = await get().getEmails(created); const emails = await get().getEmails(created);
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft); const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
if (!fresh.length) return; if (!fresh.length) return;
const { showNotification, playNewMailSound } = await import("@/lib/notify/notify");
if (s.notificationSound) playNewMailSound(); if (s.notificationSound) playNewMailSound();
if (s.desktopNotifications) { if (s.desktopNotifications) {
for (const e of fresh.slice(0, 3)) { for (const e of fresh.slice(0, 3)) {
+8 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
@@ -13,9 +13,6 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Splitter } from "@/ui/Splitter"; import { Splitter } from "@/ui/Splitter";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
import { MailboxTree } from "./mail/MailboxTree"; import { MailboxTree } from "./mail/MailboxTree";
import { FilesTree } from "./files/FilesTree";
import { ContactsSidebar } from "./contacts/ContactsSidebar";
import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { MailboxPicker } from "./mail/MailboxPicker"; import { MailboxPicker } from "./mail/MailboxPicker";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
@@ -26,6 +23,11 @@ import { hasAdministration } from "@/lib/admin/adminAccess";
import { usePermissions } from "./admin/usePermissions"; import { usePermissions } from "./admin/usePermissions";
import { AdminNav } from "./admin/AdminNav"; import { AdminNav } from "./admin/AdminNav";
// The other sections' sidebars load with the section, as their views already do.
const FilesTree = lazy(() => import("./files/FilesTree").then((m) => ({ default: m.FilesTree })));
const ContactsSidebar = lazy(() => import("./contacts/ContactsSidebar").then((m) => ({ default: m.ContactsSidebar })));
const CalendarSidebar = lazy(() => import("./calendar/CalendarSidebar").then((m) => ({ default: m.CalendarSidebar })));
/* /*
* How far the sidebar edge can be dragged. Below about 228px the module bar * How far the sidebar edge can be dragged. Below about 228px the module bar
* cuts "Calendar" and "Contacts" short in English; the floor sits a little * cuts "Calendar" and "Contacts" short in English; the floor sits a little
@@ -252,9 +254,11 @@ export function AppShell({ children }: { children: ReactNode }) {
</button> </button>
<div className="sidebar-scroll"> <div className="sidebar-scroll">
{(section === "mail" || section === "search") && <MailboxTree />} {(section === "mail" || section === "search") && <MailboxTree />}
<Suspense fallback={null}>
{section === "calendar" && <CalendarSidebar />} {section === "calendar" && <CalendarSidebar />}
{section === "contacts" && <ContactsSidebar />} {section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />} {section === "files" && <FilesTree />}
</Suspense>
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>} {section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
{section === "admin" && <AdminNav />} {section === "admin" && <AdminNav />}
</div> </div>
+16 -1
View File
@@ -1,7 +1,20 @@
import { lazy, Suspense } from "react";
import { useCompose } from "@/store/compose"; import { useCompose } from "@/store/compose";
import { Composer } from "./Composer";
import { useIsMobile } from "@/ui/misc"; import { useIsMobile } from "@/ui/misc";
/*
* The composer -- the rich-text editor, the recipient and file pickers -- is
* loaded apart from the mail view, and fetched while the browser is idle
* after startup so the first Compose does not wait on the network.
*/
const loadComposer = () => import("./Composer");
const Composer = lazy(() => loadComposer().then((m) => ({ default: m.Composer })));
if (typeof window !== "undefined") {
const warm = () => void loadComposer().catch(() => {});
if ("requestIdleCallback" in window) window.requestIdleCallback(warm, { timeout: 5000 });
else setTimeout(warm, 2000);
}
export function ComposerDock() { export function ComposerDock() {
const drafts = useCompose((s) => s.drafts); const drafts = useCompose((s) => s.drafts);
const activeKey = useCompose((s) => s.activeKey); const activeKey = useCompose((s) => s.activeKey);
@@ -13,9 +26,11 @@ export function ComposerDock() {
const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized); const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized);
return ( return (
<div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}> <div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}>
<Suspense fallback={null}>
{visible.map((d) => ( {visible.map((d) => (
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} /> <Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
))} ))}
</Suspense>
</div> </div>
); );
} }
@@ -42,35 +42,39 @@ describe("ComposerDock with a full-screen composer", () => {
useCompose.setState({ drafts: [], activeKey: null }); useCompose.setState({ drafts: [], activeKey: null });
}); });
const render = (drafts: Draft[], activeKey: string) => { // The composer is loaded on demand, so rendering waits for it to arrive.
const render = async (drafts: Draft[], activeKey: string) => {
useCompose.setState({ drafts, activeKey }); useCompose.setState({ drafts, activeKey });
act(() => root.render(<ComposerDock />)); await act(async () => {
root.render(<ComposerDock />);
await import("../Composer");
});
}; };
const dock = () => host.querySelector(".composer-dock")!; const dock = () => host.querySelector(".composer-dock")!;
it("marks the dock so the other composers are hidden behind it", () => { it("marks the dock so the other composers are hidden behind it", async () => {
setWidth(1300); setWidth(1300);
render([draft("a"), draft("b", { maximized: true }), draft("c")], "b"); await render([draft("a"), draft("b", { maximized: true }), draft("c")], "b");
expect(dock().classList.contains("has-maximized")).toBe(true); expect(dock().classList.contains("has-maximized")).toBe(true);
// Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost. // Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost.
expect(host.querySelectorAll(".composer").length).toBe(3); expect(host.querySelectorAll(".composer").length).toBe(3);
}); });
it("leaves the dock alone while nobody is full screen", () => { it("leaves the dock alone while nobody is full screen", async () => {
setWidth(1300); setWidth(1300);
render([draft("a"), draft("b")], "b"); await render([draft("a"), draft("b")], "b");
expect(dock().classList.contains("has-maximized")).toBe(false); expect(dock().classList.contains("has-maximized")).toBe(false);
}); });
it("does not count a full-screen composer that has since been minimized", () => { it("does not count a full-screen composer that has since been minimized", async () => {
setWidth(1300); setWidth(1300);
render([draft("a"), draft("b", { maximized: true, minimized: true })], "a"); await render([draft("a"), draft("b", { maximized: true, minimized: true })], "a");
expect(dock().classList.contains("has-maximized")).toBe(false); expect(dock().classList.contains("has-maximized")).toBe(false);
}); });
it("is not a phone concern: there the active composer is already the only one open", () => { it("is not a phone concern: there the active composer is already the only one open", async () => {
setWidth(400); setWidth(400);
render([draft("a"), draft("b", { maximized: true })], "b"); await render([draft("a"), draft("b", { maximized: true })], "b");
expect(dock().classList.contains("has-maximized")).toBe(false); expect(dock().classList.contains("has-maximized")).toBe(false);
}); });
}); });
+6 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useState, type MouseEvent, type ReactNode } from "react"; import { lazy, Suspense, useCallback, useState, type MouseEvent, type ReactNode } from "react";
import { Copy, Mail, Pencil, UserPlus } from "lucide-react"; import { Copy, Mail, Pencil, UserPlus } from "lucide-react";
import type { EmailAddress } from "@/jmap/types"; import type { EmailAddress } from "@/jmap/types";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
@@ -7,9 +7,11 @@ import { contactFromAddress } from "@/lib/contacts";
import { formatAddress } from "@/lib/address"; import { formatAddress } from "@/lib/address";
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover"; import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { ContactEditor } from "../contacts/ContactEditor";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
// Loaded when first opened: it is not needed to show mail, and it is not small.
const ContactEditor = lazy(() => import("../contacts/ContactEditor").then((m) => ({ default: m.ContactEditor })));
/** /**
* Right-click on anyone named in a message — sender, recipients, Reply-To — to * Right-click on anyone named in a message — sender, recipients, Reply-To — to
* add them to the address book. The contact editor opens prefilled rather than * add them to the address book. The contact editor opens prefilled rather than
@@ -65,12 +67,14 @@ export function useAddressMenu() {
</Popover> </Popover>
)} )}
{editing && ( {editing && (
<Suspense fallback={null}>
<ContactEditor <ContactEditor
card={editing} card={editing}
defaultBookId={defaultBookId} defaultBookId={defaultBookId}
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
onSaved={() => setEditing(null)} onSaved={() => setEditing(null)}
/> />
</Suspense>
)} )}
</> </>
); );
+5 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react"; import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
@@ -11,7 +11,6 @@ import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc"; import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc";
import { confirmDialog, promptDialog } from "@/ui/dialog"; import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog";
import { MailboxPicker } from "./MailboxPicker"; import { MailboxPicker } from "./MailboxPicker";
import { loadRaw, saveJson } from "@/lib/storage"; import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove"; import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
@@ -19,6 +18,9 @@ import { haptic, useTouchRow } from "@/lib/input/touch";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
// Loaded when first opened: it is not needed to show mail, and it is not small.
const ShareDialog = lazy(() => import("../settings/ShareDialog").then((m) => ({ default: m.ShareDialog })));
const ROLE_ICONS: Record<string, ReactNode> = { const ROLE_ICONS: Record<string, ReactNode> = {
inbox: <Inbox size={20} />, inbox: <Inbox size={20} />,
drafts: <File size={20} />, drafts: <File size={20} />,
@@ -273,7 +275,7 @@ export function MailboxTree() {
}} }}
/> />
)} )}
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />} {shareTarget && <Suspense fallback={null}><ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} /></Suspense>}
</> </>
); );
} }
+5 -3
View File
@@ -1,4 +1,4 @@
import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react"; import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
@@ -21,9 +21,11 @@ import { startAppointment } from "@/lib/calendar/appointment";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch"; import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch";
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe"; import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
// Loaded when first opened: it is not needed to show mail, and it is not small.
const FilterFromMessageDialog = lazy(() => import("./FilterFromMessage").then((m) => ({ default: m.FilterFromMessageDialog })));
/** /**
* The glyph on the strip a swipe reveals. Sized larger than the toolbar's * The glyph on the strip a swipe reveals. Sized larger than the toolbar's
* icons: it is read at arm's length, in motion, out of the corner of an eye. * icons: it is read at arm's length, in motion, out of the corner of an eye.
@@ -560,7 +562,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
<MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} /> <MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={t("Create event…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }} />} {hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={t("Create event…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }} />}
</Popover> </Popover>
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />} {filterFrom && <Suspense fallback={null}><FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} /></Suspense>}
</div> </div>
); );
} }
+11 -5
View File
@@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File as FileIcon, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter, Share2 } from "lucide-react"; import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File as FileIcon, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter, Share2 } from "lucide-react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types"; import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
@@ -20,7 +19,10 @@ import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address"; import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html"; import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
import { openableInTab, previewKind } from "@/lib/preview"; import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview"; // Loaded when first opened: it is not needed to show mail, and it is not small.
const FilterFromMessageDialog = lazy(() => import("./FilterFromMessage").then((m) => ({ default: m.FilterFromMessageDialog })));
// The preview carries a Markdown renderer, which is most of its weight.
const FilePreviewDialog = lazy(() => import("@/ui/filepreview").then((m) => ({ default: m.FilePreviewDialog })));
import { findQuoteStart, htmlToText, textToHtml, withoutBidiControls } from "@/lib/text/text"; import { findQuoteStart, htmlToText, textToHtml, withoutBidiControls } from "@/lib/text/text";
import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share"; import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
import { Avatar } from "@/ui/misc"; import { Avatar } from "@/ui/misc";
@@ -456,7 +458,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
</> </>
)} )}
{addrMenu.node} {addrMenu.node}
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />} {filterOpen && <Suspense fallback={null}><FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} /></Suspense>}
<Dialog open={showSource} onClose={() => setShowSource(false)} title={translate("Original message")} size="xl"> <Dialog open={showSource} onClose={() => setShowSource(false)} title={translate("Original message")} size="xl">
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>} {source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
</Dialog> </Dialog>
@@ -915,8 +917,10 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
</button> </button>
)} )}
</div> </div>
{preview && (
<Suspense fallback={null}>
<FilePreviewDialog <FilePreviewDialog
file={preview && preview.blobId ? { file={preview.blobId ? {
name: preview.name ?? translate("file"), name: preview.name ?? translate("file"),
type: preview.type, type: preview.type,
size: preview.size, size: preview.size,
@@ -926,6 +930,8 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
caption={<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>} caption={<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>}
/> />
</Suspense>
)}
</> </>
); );
} }