perf: stop re-rendering the message rows and mail view on unrelated store changes

This commit is contained in:
Linus Rath
2026-10-15 00:00:28 +02:00
parent 2d67442a2b
commit b59e3520f8
17 changed files with 258 additions and 34 deletions
@@ -22,6 +22,7 @@ import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { isDocumentRTL } from '@/i18n/direction';
import { useAuthStore } from '@/stores/auth-store';
import { useShallow } from "zustand/react/shallow";
import { useEmailStore } from '@/stores/email-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
@@ -389,7 +390,15 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore();
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
calendars: state.calendars,
supportsCalendar: state.supportsCalendar,
importEvents: state.importEvents,
rsvpEvent: state.rsvpEvent,
updateEvent: state.updateEvent,
events: state.events,
setSelectedDate: state.setSelectedDate,
})));
const [state, setState] = useState<BannerState>('loading');
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
+35 -2
View File
@@ -14,6 +14,7 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } fr
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, type ListRow } from "@/stores/email-store";
import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -116,7 +117,7 @@ export function EmailList({
const tSpam = useTranslations('email_viewer.spam');
const tNotifications = useTranslations('notifications');
const tViewer = useTranslations('email_viewer');
const { client } = useAuthStore();
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const {
selectedEmailKeys,
selectAllEmails: _selectAllEmails,
@@ -149,7 +150,39 @@ export function EmailList({
searchQuery,
isUnifiedView,
unifiedRole,
} = useEmailStore();
} = useEmailStore(useShallow((state) => ({
selectedEmailKeys: state.selectedEmailKeys,
selectAllEmails: state.selectAllEmails,
clearSelection: state.clearSelection,
batchMarkAsRead: state.batchMarkAsRead,
batchSetTag: state.batchSetTag,
batchDelete: state.batchDelete,
batchMoveToMailbox: state.batchMoveToMailbox,
batchArchive: state.batchArchive,
batchMarkAsSpam: state.batchMarkAsSpam,
batchUndoSpam: state.batchUndoSpam,
loadMoreEmails: state.loadMoreEmails,
hasMoreEmails: state.hasMoreEmails,
isLoadingMore: state.isLoadingMore,
mailboxes: state.mailboxes,
selectedMailbox: state.selectedMailbox,
emptyMailbox: state.emptyMailbox,
expandedThreadIds: state.expandedThreadIds,
threadEmailsCache: state.threadEmailsCache,
isLoadingThread: state.isLoadingThread,
toggleThreadExpansion: state.toggleThreadExpansion,
fetchThreadEmails: state.fetchThreadEmails,
markThreadAsRead: state.markThreadAsRead,
collapseAllThreads: state.collapseAllThreads,
threadEmailCounts: state.threadEmailCounts,
searchFilters: state.searchFilters,
setSearchFilters: state.setSearchFilters,
clearSearchFilters: state.clearSearchFilters,
advancedSearch: state.advancedSearch,
searchQuery: state.searchQuery,
isUnifiedView: state.isUnifiedView,
unifiedRole: state.unifiedRole,
})));
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
// there is no concrete mailbox to read the role from. Fall back to the unified
+3 -2
View File
@@ -92,6 +92,7 @@ import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
@@ -1189,8 +1190,8 @@ export function EmailViewer({
// `sm:hidden` must not be trusted - the viewport may be desktop-sized
// while the pane is phone-sized.
const isPaneScoped = useIsPaneScoped();
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const { tabletListVisible } = useUIStore(useShallow((state) => ({ tabletListVisible: state.tabletListVisible })));
const { identities, client, isDemoMode, activeAccountId } = useAuthStore(useShallow((state) => ({ identities: state.identities, client: state.client, isDemoMode: state.isDemoMode, activeAccountId: state.activeAccountId })));
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// The standard mail UI supplies message routing for preview/download,
// inline images, export and attachment drag-out. Other viewer hosts keep
+20 -1
View File
@@ -20,6 +20,7 @@ import {
} from "@/components/icons";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store";
import { runBatchEmailAction } from "@/lib/email-action-toast";
import { toast } from "@/stores/toast-store";
@@ -129,7 +130,25 @@ export function MvListToolbar({
batchSetTag,
isUnifiedView,
unifiedRole,
} = useEmailStore();
} = useEmailStore(useShallow((state) => ({
emails: state.emails,
mailboxes: state.mailboxes,
selectedMailbox: state.selectedMailbox,
selectedEmailKeys: state.selectedEmailKeys,
// Read at render by resolveSelectedEmails (Move menu owner account).
threadEmailsCache: state.threadEmailsCache,
selectAllEmails: state.selectAllEmails,
clearSelection: state.clearSelection,
batchArchive: state.batchArchive,
batchDelete: state.batchDelete,
batchMarkAsRead: state.batchMarkAsRead,
batchMarkAsSpam: state.batchMarkAsSpam,
batchUndoSpam: state.batchUndoSpam,
batchMoveToMailbox: state.batchMoveToMailbox,
batchSetTag: state.batchSetTag,
isUnifiedView: state.isUnifiedView,
unifiedRole: state.unifiedRole,
})));
const [isProcessing, setIsProcessing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
@@ -12,6 +12,7 @@ import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collaps
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
@@ -96,7 +97,7 @@ export function useConversationExternalContent() {
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
const allow = useCallback((emailId: string) => {
@@ -513,7 +514,7 @@ export function ConversationEmailBody({
);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
const { client } = useAuthStore();
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
+8 -1
View File
@@ -10,6 +10,7 @@ import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "@/
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
import { useShallow } from "zustand/react/shallow";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
@@ -45,7 +46,13 @@ export function ThreadEmailItem({
// header pools them, so without this they disappear on the way in.
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
selectedEmailKeys: state.selectedEmailKeys,
toggleEmailSelection: state.toggleEmailSelection,
selectRangeEmails: state.selectRangeEmails,
clearSelection: state.clearSelection,
})));
const density = useSettingsStore((state) => state.density);
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
+21 -2
View File
@@ -13,6 +13,7 @@ import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons";
import type { AppIcon } from "@/components/icons";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
@@ -294,7 +295,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const showPaperclip = useListHasAttachment(email, loadAttachments);
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
mailboxes: state.mailboxes,
selectedEmailKeys: state.selectedEmailKeys,
toggleEmailSelection: state.toggleEmailSelection,
selectRangeEmails: state.selectRangeEmails,
clearSelection: state.clearSelection,
isUnifiedView: state.isUnifiedView,
unifiedRole: state.unifiedRole,
})));
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
// to the unified role so junk-contextual UI and avatar hiding work.
@@ -849,7 +859,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? formatScheduledSendAt(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
mailboxes: state.mailboxes,
selectedEmailKeys: state.selectedEmailKeys,
toggleEmailSelection: state.toggleEmailSelection,
selectRangeEmails: state.selectRangeEmails,
clearSelection: state.clearSelection,
isUnifiedView: state.isUnifiedView,
unifiedRole: state.unifiedRole,
})));
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
+2 -1
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { useShallow } from "zustand/react/shallow";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
@@ -130,7 +131,7 @@ export function MobileHeader({
allInboxesSelected = false,
}: MobileHeaderProps) {
const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
const { toggleSidebar, goBack, sidebarOpen } = useUIStore(useShallow((state) => ({ toggleSidebar: state.toggleSidebar, goBack: state.goBack, sidebarOpen: state.sidebarOpen })));
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
// pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
+2 -1
View File
@@ -19,6 +19,7 @@ import type { AppIcon as AppIconType } from "@/components/icons";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
import { useCalendarStore } from "@/stores/calendar-store";
import { useConfig } from "@/hooks/use-config";
import { usePolicyStore } from "@/stores/policy-store";
@@ -107,7 +108,7 @@ export function MvTopBar({
const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed);
const client = useAuthStore((s) => s.client);
const { supportsCalendar } = useCalendarStore();
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled"));
+3 -2
View File
@@ -7,6 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
import { iconForName } from "@/components/icons";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { usePathname, Link, useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -208,8 +209,8 @@ export function NavigationRail({
const router = useRouter();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes })));
const client = useAuthStore((s) => s.client);
const supportsFiles = client?.supportsFiles() ?? false;
const supportsContacts = client?.supportsContacts() ?? false;
+6 -5
View File
@@ -57,6 +57,7 @@ import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { useShallow } from "zustand/react/shallow";
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
import { useDragDropContext } from "@/contexts/drag-drop-context";
@@ -746,7 +747,7 @@ function TagItem({
function DemoBanner() {
const t = useTranslations('sidebar');
const { isDemoMode, loginDemo } = useAuthStore();
const { isDemoMode, loginDemo } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode, loginDemo: state.loginDemo })));
const { startTour, resetTourCompletion } = useTour();
const router = useRouter();
const [isResetting, setIsResetting] = useState(false);
@@ -808,7 +809,7 @@ function DemoBanner() {
function VacationBanner() {
const t = useTranslations('sidebar');
const router = useRouter();
const { isEnabled, isSupported } = useVacationStore();
const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported })));
if (!isSupported || !isEnabled) return null;
@@ -866,7 +867,7 @@ export function Sidebar({
mvShell = false,
}: SidebarProps) {
const router = useRouter();
const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore();
const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, toggleSidebarCollapsed: state.toggleSidebarCollapsed })));
// Mountain View's collapsed rail is a peek, not a commitment: putting the pointer on
// it slides the full list back out over the message list and takes it away
// again on leaving, so a folder two clicks deep costs no clicks at all. It
@@ -875,7 +876,7 @@ export function Sidebar({
const [railHovered, setRailHovered] = useState(false);
const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
const isCollapsed = sidebarCollapsed && !hoverExpanded;
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const [showAllTags, setShowAllTags] = useState(false);
@@ -951,7 +952,7 @@ export function Sidebar({
const showUnified =
(multiAccountMode || enableUnifiedMailbox) &&
(crossAccountActive || (includeGroupInUnified && hasGroupInboxes) || anyCrossViewEnabled);
const { unifiedCounts } = useEmailStore();
const { unifiedCounts } = useEmailStore(useShallow((state) => ({ unifiedCounts: state.unifiedCounts })));
// While the other logins are still reconnecting after a load, the unified
// counts cover only the ones already back: say how many, so a partial total
// is not read as the whole. Gone as soon as the restore finishes, whatever
+124 -5
View File
@@ -28,6 +28,7 @@ import { ThreadConversationView } from "@/components/email/thread-conversation-v
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, Mailbox, type StateChange, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
import { useShallow } from "zustand/react/shallow";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
@@ -253,13 +254,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Whether the bootstrap fetch for the default mailbox has landed. Deep links
// wait for it so their folder switch isn't overwritten (#733).
const [initialMailLoadDone, setInitialMailLoadDone] = useState(false);
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore();
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(useShallow((state) => ({
isAuthenticated: state.isAuthenticated,
client: state.client,
logout: state.logout,
checkAuth: state.checkAuth,
switchAccount: state.switchAccount,
activeAccountId: state.activeAccountId,
isLoading: state.isLoading,
connectionLost: state.connectionLost,
isRateLimited: state.isRateLimited,
rateLimitUntil: state.rateLimitUntil,
})));
const { identities } = useIdentityStore(useShallow((state) => ({ identities: state.identities })));
const multiAccountIdentities = useProMultiAccountIdentities();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore();
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(useShallow((state) => ({ loadTrustedSendersBook: state.loadTrustedSendersBook, trustedSendersLoaded: state.trustedSendersLoaded, loadRecentRecipients: state.loadRecentRecipients })));
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('email_viewer.reschedule_prompt'));
@@ -342,7 +354,25 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// after a split closes and reopens at a different size.
const clampEmailListWidth = (width: number) =>
paneWidth !== null ? Math.min(width, Math.max(220, Math.floor(paneWidth * 0.55))) : width;
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(useShallow((state) => ({
activeView: state.activeView,
sidebarOpen: state.sidebarOpen,
setSidebarOpen: state.setSidebarOpen,
setActiveView: state.setActiveView,
tabletListVisible: state.tabletListVisible,
setTabletListVisible: state.setTabletListVisible,
sidebarWidth: state.sidebarWidth,
emailListWidth: state.emailListWidth,
emailListHeight: state.emailListHeight,
setSidebarWidth: state.setSidebarWidth,
setEmailListWidth: state.setEmailListWidth,
setEmailListHeight: state.setEmailListHeight,
persistColumnWidths: state.persistColumnWidths,
sidebarCollapsed: state.sidebarCollapsed,
resetSidebarWidth: state.resetSidebarWidth,
resetEmailListWidth: state.resetEmailListWidth,
resetEmailListHeight: state.resetEmailListHeight,
})));
const {
emails,
mailboxes,
@@ -432,7 +462,96 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
selectAccountMailbox,
setViewingAccount,
refreshCurrentMailbox,
} = useEmailStore();
} = useEmailStore(useShallow((state) => ({
emails: state.emails,
mailboxes: state.mailboxes,
selectedEmail: state.selectedEmail,
selectedMailbox: state.selectedMailbox,
quota: state.quota,
isPushConnected: state.isPushConnected,
newEmailNotification: state.newEmailNotification,
selectEmail: state.selectEmail,
selectMailbox: state.selectMailbox,
selectedEmailKeys: state.selectedEmailKeys,
selectAllEmails: state.selectAllEmails,
clearSelection: state.clearSelection,
toggleEmailSelection: state.toggleEmailSelection,
fetchMailboxes: state.fetchMailboxes,
fetchEmails: state.fetchEmails,
fetchQuota: state.fetchQuota,
sendEmail: state.sendEmail,
deleteEmail: state.deleteEmail,
markAsRead: state.markAsRead,
toggleStar: state.toggleStar,
patchEmailKeywords: state.patchEmailKeywords,
moveToMailbox: state.moveToMailbox,
moveToMailboxCrossAware: state.moveToMailboxCrossAware,
moveThreadToMailbox: state.moveThreadToMailbox,
searchEmails: state.searchEmails,
searchQuery: state.searchQuery,
setSearchQuery: state.setSearchQuery,
isLoading: state.isLoading,
isLoadingEmail: state.isLoadingEmail,
setLoadingEmail: state.setLoadingEmail,
setPushConnected: state.setPushConnected,
handleStateChange: state.handleStateChange,
clearNewEmailNotification: state.clearNewEmailNotification,
markAsSpam: state.markAsSpam,
undoSpam: state.undoSpam,
searchFilters: state.searchFilters,
searchMailboxId: state.searchMailboxId,
isAdvancedSearchOpen: state.isAdvancedSearchOpen,
setSearchFilters: state.setSearchFilters,
setSearchMailboxId: state.setSearchMailboxId,
scopeSearchToOpenFolder: state.scopeSearchToOpenFolder,
clearSearchFilters: state.clearSearchFilters,
toggleAdvancedSearch: state.toggleAdvancedSearch,
advancedSearch: state.advancedSearch,
selectedKeyword: state.selectedKeyword,
selectKeyword: state.selectKeyword,
hasMoreEmails: state.hasMoreEmails,
fetchTagCounts: state.fetchTagCounts,
fetchEmailContent: state.fetchEmailContent,
isUnifiedView: state.isUnifiedView,
unifiedRole: state.unifiedRole,
scheduledEmails: state.scheduledEmails,
scheduledTotal: state.scheduledTotal,
scheduledTotalByAccount: state.scheduledTotalByAccount,
scheduledHasMore: state.scheduledHasMore,
isLoadingScheduled: state.isLoadingScheduled,
isScheduledView: state.isScheduledView,
scheduledAccountScope: state.scheduledAccountScope,
setScheduledView: state.setScheduledView,
fetchScheduledEmails: state.fetchScheduledEmails,
loadMoreScheduledEmails: state.loadMoreScheduledEmails,
cancelScheduledEmailForEdit: state.cancelScheduledEmailForEdit,
rescheduleScheduledEmail: state.rescheduleScheduledEmail,
refreshScheduledMetadata: state.refreshScheduledMetadata,
cancelUndoSend: state.cancelUndoSend,
clearPendingUndoSend: state.clearPendingUndoSend,
pendingUndoSend: state.pendingUndoSend,
fetchUnifiedEmails: state.fetchUnifiedEmails,
fetchCrossView: state.fetchCrossView,
refreshUnifiedCounts: state.refreshUnifiedCounts,
refreshCrossCounts: state.refreshCrossCounts,
crossUnreadCount: state.crossUnreadCount,
exitUnifiedView: state.exitUnifiedView,
emptyMailbox: state.emptyMailbox,
markMailboxAsRead: state.markMailboxAsRead,
createMailbox: state.createMailbox,
renameMailbox: state.renameMailbox,
deleteMailbox: state.deleteMailbox,
batchDelete: state.batchDelete,
batchArchive: state.batchArchive,
batchMarkAsRead: state.batchMarkAsRead,
batchMarkAsSpam: state.batchMarkAsSpam,
batchUndoSpam: state.batchUndoSpam,
accountMailboxes: state.accountMailboxes,
viewingAccountId: state.viewingAccountId,
selectAccountMailbox: state.selectAccountMailbox,
setViewingAccount: state.setViewingAccount,
refreshCurrentMailbox: state.refreshCurrentMailbox,
})));
// Mailboxes of the account currently being browsed. Mirrors the store's
// action-mailbox resolution so a lookup by `selectedMailbox` hits the same
+4 -3
View File
@@ -3,6 +3,7 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import { useRouter, usePathname } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -37,10 +38,10 @@ export function useTour() {
export function TourProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore();
const { isDemoMode } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode })));
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { supportsWebDAV } = useWebDAVStore();
const { supportsWebDAV } = useWebDAVStore(useShallow((state) => ({ supportsWebDAV: state.supportsWebDAV })));
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const updateSetting = useSettingsStore((s) => s.updateSetting);
+2 -1
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { IJMAPClient } from "@/lib/jmap/client-interface";
import { useEmailStore } from "@/stores/email-store";
import { useShallow } from "zustand/react/shallow";
import { emailKeyFor } from "@/lib/thread-utils";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
@@ -142,7 +143,7 @@ function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
}
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailKeys, emails } = useEmailStore();
const { selectedEmailKeys, emails } = useEmailStore(useShallow((state) => ({ selectedEmailKeys: state.selectedEmailKeys, emails: state.emails })));
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const isMobile = useUIStore((state) => state.isMobile);
const client = useAuthStore((state) => state.client);
+10 -2
View File
@@ -3,6 +3,7 @@
import { useCallback, useState, DragEvent } from "react";
import { Mailbox, Email } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useShallow } from "zustand/react/shallow";
import { emailKeyFor } from "@/lib/thread-utils";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
@@ -66,8 +67,15 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(useShallow((state) => ({
moveEmailsToMailbox: state.moveEmailsToMailbox,
crossAccountMoveEmails: state.crossAccountMoveEmails,
selectedEmailKeys: state.selectedEmailKeys,
clearSelection: state.clearSelection,
refreshCurrentMailbox: state.refreshCurrentMailbox,
mailboxes: state.mailboxes,
})));
const { isDragging, sourceMailboxId, draggedEmails, endDrag, markDropped } = useDragDropContext();
// Determine if this is a valid drop target
+2 -1
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store";
import { useShallow } from "zustand/react/shallow";
import { usePaneSize } from "@/hooks/use-pane-size";
// Tailwind v4 breakpoints
@@ -68,7 +69,7 @@ function classifyPane(paneWidth: number | null) {
* it directly (mobile navigation helpers etc.).
*/
export function useDeviceDetection() {
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isTablet: state.isTablet, isDesktop: state.isDesktop })));
const paneWidth = usePaneSize();
const paneClassification = classifyPane(paneWidth);
+3 -2
View File
@@ -2,6 +2,7 @@
import { useCallback, useState, DragEvent } from "react";
import { useEmailStore } from "@/stores/email-store";
import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { dragDropHooks } from "@/lib/plugin-hooks";
@@ -26,8 +27,8 @@ interface UseTagDropReturn {
export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore();
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore(useShallow((state) => ({ fetchEmails: state.fetchEmails, fetchTagCounts: state.fetchTagCounts, selectedMailbox: state.selectedMailbox })));
const { isDragging, endDrag, markDropped } = useDragDropContext();
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {