diff --git a/web/src/App.tsx b/web/src/App.tsx
index 3645092..826824e 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -16,7 +16,7 @@ import { LoginPage } from "@/views/Login";
import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView";
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 { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
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;
useEffect(() => {
- void import("@/lib/notify/notify").then((m) => {
- m.setBaseTitle(appName);
- setUnreadBadge(inboxUnread);
- });
+ setBaseTitle(appName);
+ setUnreadBadge(inboxUnread);
}, [inboxUnread, appName]);
/*
@@ -284,7 +282,7 @@ function AuthedApp() {
// Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications);
useEffect(() => {
- if (notif) void import("@/lib/notify/notify").then((m) => m.requestNotificationPermission());
+ if (notif) void requestNotificationPermission();
}, [notif]);
// Nothing worth painting until the account's settings are in force; see the
diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts
index 9b2ee3c..21fadf8 100644
--- a/web/src/store/mail/index.ts
+++ b/web/src/store/mail/index.ts
@@ -28,6 +28,7 @@ import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath";
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
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
@@ -1206,7 +1207,6 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
const emails = await get().getEmails(created);
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
if (!fresh.length) return;
- const { showNotification, playNewMailSound } = await import("@/lib/notify/notify");
if (s.notificationSound) playNewMailSound();
if (s.desktopNotifications) {
for (const e of fresh.slice(0, 3)) {
diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx
index f716ad9..d31181b 100644
--- a/web/src/views/AppShell.tsx
+++ b/web/src/views/AppShell.tsx
@@ -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 { 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";
@@ -13,9 +13,6 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Splitter } from "@/ui/Splitter";
import { SearchBar } from "./SearchBar";
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 { MailboxPicker } from "./mail/MailboxPicker";
import { formatSize } from "@/lib/format";
@@ -26,6 +23,11 @@ import { hasAdministration } from "@/lib/admin/adminAccess";
import { usePermissions } from "./admin/usePermissions";
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
* cuts "Calendar" and "Contacts" short in English; the floor sits a little
@@ -252,9 +254,11 @@ export function AppShell({ children }: { children: ReactNode }) {
{(section === "mail" || section === "search") &&
}
- {section === "calendar" &&
}
- {section === "contacts" &&
}
- {section === "files" &&
}
+
+ {section === "calendar" && }
+ {section === "contacts" && }
+ {section === "files" && }
+
{section === "settings" &&
{t("Settings")}
}
{section === "admin" &&
}
diff --git a/web/src/views/compose/ComposerDock.tsx b/web/src/views/compose/ComposerDock.tsx
index 878e5ee..8685779 100644
--- a/web/src/views/compose/ComposerDock.tsx
+++ b/web/src/views/compose/ComposerDock.tsx
@@ -1,7 +1,20 @@
+import { lazy, Suspense } from "react";
import { useCompose } from "@/store/compose";
-import { Composer } from "./Composer";
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() {
const drafts = useCompose((s) => s.drafts);
const activeKey = useCompose((s) => s.activeKey);
@@ -13,9 +26,11 @@ export function ComposerDock() {
const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized);
return (
- {visible.map((d) => (
-
- ))}
+
+ {visible.map((d) => (
+
+ ))}
+
);
}
diff --git a/web/src/views/compose/__tests__/composer-dock.test.tsx b/web/src/views/compose/__tests__/composer-dock.test.tsx
index 0f79cef..498e9c5 100644
--- a/web/src/views/compose/__tests__/composer-dock.test.tsx
+++ b/web/src/views/compose/__tests__/composer-dock.test.tsx
@@ -42,35 +42,39 @@ describe("ComposerDock with a full-screen composer", () => {
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 });
- act(() => root.render());
+ await act(async () => {
+ root.render();
+ await import("../Composer");
+ });
};
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);
- 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);
// Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost.
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);
- render([draft("a"), draft("b")], "b");
+ await render([draft("a"), draft("b")], "b");
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);
- 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);
});
- 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);
- 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);
});
});
diff --git a/web/src/views/mail/AddressMenu.tsx b/web/src/views/mail/AddressMenu.tsx
index d0da565..bd5d6cf 100644
--- a/web/src/views/mail/AddressMenu.tsx
+++ b/web/src/views/mail/AddressMenu.tsx
@@ -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 type { EmailAddress } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
@@ -7,9 +7,11 @@ import { contactFromAddress } from "@/lib/contacts";
import { formatAddress } from "@/lib/address";
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
import { toast } from "@/ui/toast";
-import { ContactEditor } from "../contacts/ContactEditor";
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
* add them to the address book. The contact editor opens prefilled rather than
@@ -65,12 +67,14 @@ export function useAddressMenu() {
)}
{editing && (
+
setEditing(null)}
onSaved={() => setEditing(null)}
/>
+
)}
>
);
diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx
index 9e64aef..222a139 100644
--- a/web/src/views/mail/MailboxTree.tsx
+++ b/web/src/views/mail/MailboxTree.tsx
@@ -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 { 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";
@@ -11,7 +11,6 @@ import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
-import { ShareDialog } from "../settings/ShareDialog";
import { MailboxPicker } from "./MailboxPicker";
import { loadRaw, saveJson } from "@/lib/storage";
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 { 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 = {
inbox: ,
drafts: ,
@@ -273,7 +275,7 @@ export function MailboxTree() {
}}
/>
)}
- {shareTarget && setShareTarget(null)} />}
+ {shareTarget && setShareTarget(null)} />}
>
);
}
diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx
index 9af67a9..cd549c7 100644
--- a/web/src/views/mail/MessageList.tsx
+++ b/web/src/views/mail/MessageList.tsx
@@ -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 { 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";
@@ -21,9 +21,11 @@ import { startAppointment } from "@/lib/calendar/appointment";
import { toast } from "@/ui/toast";
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch";
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe";
-import { FilterFromMessageDialog } from "./FilterFromMessage";
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
* 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,
} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
{hasCalendar && } 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)); }} />}
- {filterFrom && setFilterFrom(null)} />}
+ {filterFrom && setFilterFrom(null)} />}
);
}
diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx
index e18dc5d..5aa22a4 100644
--- a/web/src/views/mail/MessageView.tsx
+++ b/web/src/views/mail/MessageView.tsx
@@ -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 { useLocation } from "wouter";
-import { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
@@ -20,7 +19,10 @@ import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
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 { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
import { Avatar } from "@/ui/misc";
@@ -456,7 +458,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
>
)}
{addrMenu.node}
- {filterOpen && setFilterOpen(false)} />}
+ {filterOpen && setFilterOpen(false)} />}
@@ -915,8 +917,10 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
)}
+ {preview && (
+
setPreview(null)}
caption={{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}
}
/>
+
+ )}
>
);
}