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:
+3
-5
@@ -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
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user