Merge pull request #382 from Coffey-Labs/perf/lazy-chunks

Load the composer, previews, dialogs and other sidebars on demand
This commit is contained in:
jcoffey
2026-09-16 09:50:14 -07:00
committed by GitHub
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>
)}
</> </>
); );
} }