mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
perf: stop re-rendering the message rows and mail view on unrelated store changes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
Reference in New Issue
Block a user