diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index dc9cc7a12..395beac22 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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('loading'); const [parsedEvent, setParsedEvent] = useState | null>(null); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index cb6364fde..0ee95d697 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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 diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index df59d15dd..b5a866dd1 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index d26ee1fc2..1a3117ff7 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -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); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 3acf3b128..c60315b12 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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>(new Set()); const allow = useCallback((emailId: string) => { @@ -513,7 +514,7 @@ export function ConversationEmailBody({ ); const [hasBlockedContent, setHasBlockedContent] = useState(false); const [cidBlobUrls, setCidBlobUrls] = useState>({}); - 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(null); const [plainBodyEl, setPlainBodyEl] = useState(null); diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 83f54244d..c099fb225 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -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)); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 5ed9264fb..c9bb86224 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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( 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 ({ + 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; diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 3dc6db4cd..654c47625 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -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 diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx index 399ec8f2c..1785cb858 100644 --- a/components/layout/mv-top-bar.tsx +++ b/components/layout/mv-top-bar.tsx @@ -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")); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 8152d454d..e325f8264 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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; diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a18d81780..9f7c279b2 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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>(new Set()); const [expandedTags, setExpandedTags] = useState>(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 diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index f9ae1b1f9..ee7719b73 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index 1f3382283..cce7e4653 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -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); diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 1a0c8cdee..e4e6db992 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -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); diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 5531da435..189719e1e 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -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 diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts index b61f356f1..67de1f276 100644 --- a/hooks/use-media-query.ts +++ b/hooks/use-media-query.ts @@ -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); diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index 93629668a..58823d50c 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -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) => {