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)} />} setShowSource(false)} title={translate("Original message")} size="xl"> {source === null ?
:
{source}
}
@@ -915,8 +917,10 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB )} + {preview && ( + setPreview(null)} caption={

{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}

} /> +
+ )} ); }