diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0838a673..ee18ddc8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -55,6 +55,7 @@ import type { IpcResponse, InboxSplit, Snippet, + Config, } from "../shared/types"; import type { ScopedAgentEvent, AgentProviderConfig } from "../shared/agent-types"; import { mergeAndThreadSearchResults } from "./utils/searchResults"; @@ -83,12 +84,40 @@ function formatSearchDate(dateStr: string): string { return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } +function getDefaultAccountId(accounts: Account[]): string | null { + return accounts.find((account) => account.isPrimary)?.id || accounts[0]?.id || null; +} + +function resolveStoredAccountSelection( + config: Pick | undefined, + accounts: Account[], +): string | null { + if (!config || !Object.prototype.hasOwnProperty.call(config, "selectedAccountId")) { + return getDefaultAccountId(accounts); + } + + if (config.selectedAccountId === null) { + return null; + } + + if ( + typeof config.selectedAccountId === "string" && + accounts.some((account) => account.id === config.selectedAccountId) + ) { + return config.selectedAccountId; + } + + return getDefaultAccountId(accounts); +} + function SearchResultThreadRow({ thread, + accountLabel, isSelected, onClick, }: { thread: EmailThread; + accountLabel?: string; isSelected: boolean; onClick: () => void; }) { @@ -134,6 +163,18 @@ function SearchResultThreadRow({ {senderName} + {accountLabel && ( + + {accountLabel} + + )} + {/* Sent badge - show if user replied (latest email is from user) */} {thread.userReplied && ( s.activeSearchQuery); + const activeSearchResults = useAppStore((s) => s.activeSearchResults); + const remoteSearchResults = useAppStore((s) => s.remoteSearchResults); + const remoteSearchStatus = useAppStore((s) => s.remoteSearchStatus); + const clearActiveSearch = useAppStore((s) => s.clearActiveSearch); + const addEmails = useAppStore((s) => s.addEmails); + const setSelectedEmailId = useAppStore((s) => s.setSelectedEmailId); + const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId); + const setViewMode = useAppStore((s) => s.setViewMode); + const selectedThreadId = useAppStore((s) => s.selectedThreadId); + const setRemoteSearchResults = useAppStore((s) => s.setRemoteSearchResults); + const setRemoteSearchError = useAppStore((s) => s.setRemoteSearchError); + const currentAccountId = useAppStore((s) => s.currentAccountId); + const accounts = useAppStore((s) => s.accounts); + const isOnline = useAppStore((s) => s.isOnline); + const remoteSearchNextPageToken = useAppStore((s) => s.remoteSearchNextPageToken); + const remoteSearchLoadingMore = useAppStore((s) => s.remoteSearchLoadingMore); const currentUserEmail = accounts.find((a) => a.id === currentAccountId)?.email; + const currentUserEmailsByAccount = useMemo( + () => + new Map( + accounts + .map((account) => [account.id, account.email] as const) + .filter((entry) => entry[1].length > 0), + ), + [accounts], + ); const scrollContainerRef = useRef(null); const sentinelRef = useRef(null); @@ -361,8 +408,19 @@ function SearchResultsView() { // Merge local and remote results, deduplicate, and group into threads const searchThreads = useMemo( - () => mergeAndThreadSearchResults(activeSearchResults, remoteSearchResults, currentUserEmail), - [activeSearchResults, remoteSearchResults, currentUserEmail], + () => + mergeAndThreadSearchResults( + activeSearchResults, + remoteSearchResults, + currentAccountId ? currentUserEmail : currentUserEmailsByAccount, + ), + [ + activeSearchResults, + remoteSearchResults, + currentAccountId, + currentUserEmail, + currentUserEmailsByAccount, + ], ); const hasMoreResults = !!remoteSearchNextPageToken && remoteSearchStatus === "complete"; @@ -446,9 +504,16 @@ function SearchResultsView() {
{searchThreads.map((thread) => ( account.id === thread.latestEmail.accountId) + ?.email.split("@")[0] + : undefined + } onClick={() => handleThreadClick(thread)} /> ))} @@ -819,13 +884,18 @@ export default function App() { isConnected: accountList.find((a) => a.id === acc.id)?.isConnected ?? false, }), ); - setAccounts(fullAccounts); - // Set current account to primary or first available + const settingsResult = (await window.api.settings.get()) as IpcResponse; + const selectedAccountId = resolveStoredAccountSelection( + settingsResult.success ? settingsResult.data : undefined, + fullAccounts, + ); + setAccounts(fullAccounts, selectedAccountId); + + // Identify user in analytics with the primary account even if the UI + // opens in the unified account view. const primaryAccount = fullAccounts.find((a) => a.isPrimary) || fullAccounts[0]; if (primaryAccount) { - setCurrentAccountId(primaryAccount.id); - // Identify user in PostHog using primary email identifyUser(primaryAccount.email, { account_count: fullAccounts.length, }); @@ -885,7 +955,7 @@ export default function App() { context: "initializeSync", }); } - }, [setAccounts, setCurrentAccountId, addEmails, setSentEmails]); + }, [setAccounts, addEmails, setSentEmails]); // Set up sync event listeners useEffect(() => { @@ -1107,7 +1177,8 @@ export default function App() { // Save sidebar tab — startAgentTask unconditionally sets it to "agent", // but background auto-drafts shouldn't steal focus from the user const prevTab = store.sidebarTab; - store.startAgentTask(taskId, emailId, ["claude"], "", { + const providerId = event.providerId ?? "codex"; + store.startAgentTask(taskId, emailId, [providerId], "", { accountId: email.accountId || "", currentEmailId: emailId, currentThreadId: email.threadId, @@ -1315,11 +1386,14 @@ export default function App() { hasCredentials: boolean; hasTokens: boolean; hasAnthropicKey: boolean; + codexCliAvailable: boolean; + hasCodexAuth: boolean; + hasLlmAuth: boolean; }>, ) => { if (result.success) { // Credentials are always bundled at build time — only check API key and tokens - setNeedsSetup(!result.data.hasAnthropicKey || !result.data.hasTokens); + setNeedsSetup(!result.data.hasLlmAuth || !result.data.hasTokens); } else { setNeedsSetup(true); } @@ -1382,7 +1456,7 @@ export default function App() { const hasActiveProgressiveSync = Object.values(syncProgress).some( (p) => p !== null && p.fetched < p.total, ); - const { refetch: fetchEmails, isFetching } = useQuery({ + const { isFetching } = useQuery({ queryKey: ["emails", currentAccountId], queryFn: async () => { const result = await window.api.gmail.fetchUnread(100, currentAccountId ?? undefined); @@ -1463,8 +1537,20 @@ export default function App() { // Reload sent emails too await reloadSentEmailsForAccount(currentAccountId); } else { - // Fallback to legacy fetch for default account - await fetchEmails(); + await Promise.all(accounts.map((account) => window.api.sync.now(account.id))); + const [emailResults, sentResults] = await Promise.all([ + Promise.all(accounts.map((account) => window.api.sync.getEmails(account.id))), + Promise.all(accounts.map((account) => window.api.sync.getSentEmails(account.id))), + ]); + const refreshedEmails = emailResults.flatMap((result) => + result.success && result.data ? result.data : [], + ); + const refreshedSent = sentResults.flatMap((result) => + result.success && result.data ? result.data : [], + ); + setEmails(refreshedEmails); + setSentEmails(refreshedSent); + prefetchEmailBodies(refreshedEmails.map((e: DashboardEmail) => e.id)).catch(console.error); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch emails"); @@ -1487,6 +1573,7 @@ export default function App() { setAccounts(updatedAccounts); setCurrentAccountId(accountId); + window.api.settings.set({ selectedAccountId: accountId }).catch(console.error); // Load emails from DB now that sync is running const emailsResult = await window.api.sync.getEmails(accountId); @@ -1551,10 +1638,14 @@ export default function App() { const currentAccount = accounts.find((a) => a.id === currentAccountId); const currentSyncStatus = currentAccountId ? syncStatuses.get(currentAccountId) || "idle" - : "idle"; + : accounts.some((account) => (syncStatuses.get(account.id) || "idle") === "syncing") + ? "syncing" + : accounts.some((account) => (syncStatuses.get(account.id) || "idle") === "error") + ? "error" + : "idle"; const isSyncing = currentSyncStatus === "syncing"; const isCurrentAccountExpired = - currentAccountId != null && expiredAccountIds.has(currentAccountId); + currentAccountId != null ? expiredAccountIds.has(currentAccountId) : expiredAccountIds.size > 0; // Build list of expired accounts with their email addresses for the banner const expiredAccounts = accounts.filter((a) => expiredAccountIds.has(a.id)); @@ -1563,11 +1654,22 @@ export default function App() { // already in the store from initial load + background sync. We just flip // currentAccountId and let useThreadedEmails filter; background sync // picks up anything new without blocking the UI. - const handleAccountSwitch = (accountId: string) => { + const handleAccountSwitch = (accountId: string | null) => { setCurrentAccountId(accountId); + window.api.settings.set({ selectedAccountId: accountId }).catch(console.error); setAccountMenuOpen(false); - trackEvent("account_switched", { account_count: accounts.length }); + trackEvent("account_switched", { + account_count: accounts.length, + scope: accountId ?? "all", + }); + + if (accountId === null) { + accounts.forEach((account) => { + window.api.sync.now(account.id).catch(console.error); + }); + return; + } // Backfill inbox/sent emails independently if missing for this account. const storeState = useAppStore.getState(); @@ -1634,7 +1736,9 @@ export default function App() { className="flex items-center space-x-2 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors" > - {currentAccount?.email || "Select account"} + {currentAccountId === null + ? "All accounts" + : currentAccount?.email || "Select account"} {/* Sync status indicator */} {isSyncing && ( @@ -1686,6 +1790,25 @@ export default function App() { {accountMenuOpen && (
+ {accounts.map((account) => (
+ {accountLabel && ( + + {accountLabel} + + )} + {/* Draft badge */} { - if (!iframeSrcDoc) return; - const srcRegex = /]+src=["'](https?:\/\/[^"']+)["']/gi; - let match; - while ((match = srcRegex.exec(iframeSrcDoc)) !== null) { - const img = new Image(); - img.src = match[1]; - } - }, [iframeSrcDoc]); - useEffect(() => { if (!iframeRef.current || !shouldRenderIframe || !iframeSrcDoc) return; @@ -2218,25 +2204,22 @@ interface EmailDetailProps { } export function EmailDetail({ isFullView = false }: EmailDetailProps) { - const { - emails, - selectedEmailId, - selectedThreadId, - setSelectedEmailId, - setSelectedThreadId: _setSelectedThreadId, - updateEmail, - addEmails, - removeEmailsAndAdvance, - markThreadAsRead, - setViewMode, - accounts, - currentAccountId, - composeState, - closeCompose, - openCompose, - removeLocalDraft, - addLocalDraft, - } = useAppStore(); + const emails = useAppStore((s) => s.emails); + const selectedEmailId = useAppStore((s) => s.selectedEmailId); + const selectedThreadId = useAppStore((s) => s.selectedThreadId); + const setSelectedEmailId = useAppStore((s) => s.setSelectedEmailId); + const updateEmail = useAppStore((s) => s.updateEmail); + const addEmails = useAppStore((s) => s.addEmails); + const removeEmailsAndAdvance = useAppStore((s) => s.removeEmailsAndAdvance); + const markThreadAsRead = useAppStore((s) => s.markThreadAsRead); + const setViewMode = useAppStore((s) => s.setViewMode); + const accounts = useAppStore((s) => s.accounts); + const currentAccountId = useAppStore((s) => s.currentAccountId); + const composeState = useAppStore((s) => s.composeState); + const closeCompose = useAppStore((s) => s.closeCompose); + const openCompose = useAppStore((s) => s.openCompose); + const removeLocalDraft = useAppStore((s) => s.removeLocalDraft); + const addLocalDraft = useAppStore((s) => s.addLocalDraft); const addRecentlyRepliedThread = useAppStore((s) => s.addRecentlyRepliedThread); const addUndoAction = useAppStore((s) => s.addUndoAction); @@ -2340,9 +2323,15 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { const selectedEmail = storeEmail ?? fetchedEmail; - // Get current user email for "Me" detection - const currentAccount = accounts.find((a) => a.id === currentAccountId); - const currentUserEmail = currentAccount?.email; + const selectedAccountId = selectedEmail?.accountId ?? currentAccountId; + const selectedAccount = accounts.find((a) => a.id === selectedAccountId); + const currentUserEmail = selectedAccount?.email; + const composeAccountId = + currentAccountId ?? + selectedEmail?.accountId ?? + accounts.find((account) => account.isPrimary)?.id ?? + accounts[0]?.id ?? + null; // State to hold full thread emails fetched from Gmail (includes sent replies) const [fullThreadEmails, setFullThreadEmails] = useState([]); @@ -2350,7 +2339,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { // Fetch full thread when thread changes useEffect(() => { - if (!selectedEmail || !currentAccountId) { + if (!selectedEmail || !selectedAccountId) { setFullThreadEmails([]); return; } @@ -2360,7 +2349,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { try { const response = await window.api.emails.getThread( selectedEmail.threadId, - currentAccountId, + selectedAccountId, ); if (response.success && response.data) { setFullThreadEmails(response.data); @@ -2376,7 +2365,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { }; fetchThread(); - }, [selectedEmail?.threadId, currentAccountId]); + }, [selectedEmail?.threadId, selectedAccountId]); // Mark-as-read is handled imperatively in the Enter/click handlers // (store.markThreadAsRead) — not here — so it fires instantly before render. @@ -2387,7 +2376,9 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { if (!selectedEmail) return []; // Start with emails from the store - const storeEmails = emails.filter((e) => e.threadId === selectedEmail.threadId); + const storeEmails = emails.filter( + (e) => e.threadId === selectedEmail.threadId && e.accountId === selectedEmail.accountId, + ); // Merge with full thread emails. Store versions have analysis/draft info, // but may have empty bodies (bulk queries exclude body for performance). @@ -2731,7 +2722,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { // send time — they don't affect the visible UI. const composeRequestIdRef = useRef(0); useEffect(() => { - if (isFullView && composeState?.isOpen && composeState.replyToEmailId && currentAccountId) { + if (isFullView && composeState?.isOpen && composeState.replyToEmailId && selectedAccountId) { const mode = composeState.mode; if (mode === "reply" || mode === "reply-all" || mode === "forward") { const requestId = ++composeRequestIdRef.current; @@ -2740,7 +2731,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { // by composeState, not reactive to email/account changes). const storeState = useAppStore.getState(); const storeEmails = storeState.emails; - const acct = storeState.accounts.find((a) => a.id === currentAccountId); + const acct = storeState.accounts.find((a) => a.id === selectedAccountId); const userEmail = acct?.email; // Find the email in the thread that has a draft (may not be the latest) const threadId = storeEmails.find((e) => e.id === composeState.replyToEmailId)?.threadId; @@ -2778,7 +2769,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { // These only matter at send time for Gmail threading — the UI is // already fully interactive without them. window.api.compose - .getReplyInfo(composeState.replyToEmailId, mode, currentAccountId) + .getReplyInfo(composeState.replyToEmailId, mode, selectedAccountId) .then((response: IpcResponse) => { if (requestId !== composeRequestIdRef.current) return; if (response.success && response.data) { @@ -2802,7 +2793,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { // Fall back to the IPC call. setIsLoadingReplyInfo(true); window.api.compose - .getReplyInfo(composeState.replyToEmailId, mode, currentAccountId) + .getReplyInfo(composeState.replyToEmailId, mode, selectedAccountId) .then((response: IpcResponse) => { if (requestId !== composeRequestIdRef.current) return; if (!response.success || !response.data) { @@ -2821,7 +2812,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { } } } - }, [isFullView, composeState, currentAccountId, closeCompose, setInlineReplyOpen]); + }, [isFullView, composeState, selectedAccountId, closeCompose, setInlineReplyOpen]); // Safety net: if we're in full view with no valid email and no compose open, // fall back to split view so the email list becomes visible. This catches edge @@ -2865,11 +2856,11 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { // Add the sent email to the store optimistically. // Always use the current thread's threadId so forwards appear in the // thread view alongside the original email, just like replies do. - if (currentAccountId && currentUserEmail) { + if (selectedAccountId && currentUserEmail) { const sentEmail: DashboardEmail = { id: sentInfo.id, threadId: selectedEmail?.threadId ?? sentInfo.threadId, - accountId: currentAccountId, + accountId: selectedAccountId, from: currentUserEmail, to: sentInfo.to.join(", "), cc: sentInfo.cc?.join(", "), @@ -2917,8 +2908,8 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { setRestoredDraft(null); setInlineReplyOpen(false); // Also trigger sync to ensure we have the canonical version - if (currentAccountId) { - window.api.sync.now(currentAccountId); + if (selectedAccountId) { + window.api.sync.now(selectedAccountId); } }; @@ -2963,8 +2954,8 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { closeCompose(); setViewMode("split"); // Trigger sync to get the sent message - if (currentAccountId) { - window.api.sync.now(currentAccountId); + if (composeAccountId) { + window.api.sync.now(composeAccountId); } }; @@ -2983,7 +2974,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { formState.bodyHtml.replace(/<[^>]*>/g, "").trim(); const existingDraftId = composeState?.restoredDraft?.localDraftId; - if (hasContent && currentAccountId) { + if (hasContent && composeAccountId) { if (existingDraftId) { // Update existing draft with current form state await window.api.compose.updateLocalDraft(existingDraftId, { @@ -3007,7 +2998,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { } else { // Save new draft const result = (await window.api.compose.saveLocalDraft({ - accountId: currentAccountId, + accountId: composeAccountId, to: formState.to, cc: formState.cc.length > 0 ? formState.cc : undefined, bcc: formState.bcc.length > 0 ? formState.bcc : undefined, @@ -3036,10 +3027,10 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { }; // Show new email compose view when in "new" compose mode - if (composeState?.isOpen && composeState.mode === "new" && currentAccountId) { + if (composeState?.isOpen && composeState.mode === "new" && composeAccountId) { return ( e.labelIds?.includes("STARRED")); const handleArchive = () => { - if (!currentAccountId || !selectedThreadId) return; + if (!selectedAccountId || !selectedThreadId) return; const emailIds = threadEmails.map((e) => e.id); // Find next thread before removing @@ -3124,7 +3115,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { id: `archive-${selectedThreadId}-${Date.now()}`, type: "archive", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [...threadEmails], scheduledAt: Date.now(), delayMs: 5000, @@ -3132,7 +3123,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { }; const handleTrash = () => { - if (!currentAccountId || !selectedThreadId) return; + if (!selectedAccountId || !selectedThreadId) return; const emailIds = threadEmails.map((e) => e.id); // Find next thread before removing (same auto-advance as archive) @@ -3159,7 +3150,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { id: `trash-${selectedThreadId}-${Date.now()}`, type: "trash", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [...threadEmails], scheduledAt: Date.now(), delayMs: 5000, @@ -3167,7 +3158,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { }; const handleMarkUnread = () => { - if (!currentAccountId || !latestEmail) return; + if (!selectedAccountId || !latestEmail) return; const currentLabels = latestEmail.labelIds || ["INBOX"]; // Optimistic update + undo — only if email was actually modified @@ -3178,7 +3169,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { id: `mark-unread-${selectedThreadId}-${Date.now()}`, type: "mark-unread", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [latestEmail], scheduledAt: Date.now(), delayMs: 5000, @@ -3190,7 +3181,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { }; const handleToggleStar = () => { - if (!currentAccountId || !latestEmail) return; + if (!selectedAccountId || !latestEmail) return; const newStarred = !isStarred; const changedEmails: typeof threadEmails = []; const previousLabels: Record = {}; @@ -3220,7 +3211,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { id: `${newStarred ? "star" : "unstar"}-${selectedThreadId}-${Date.now()}`, type: newStarred ? "star" : "unstar", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: changedEmails, scheduledAt: Date.now(), delayMs: 5000, @@ -3406,7 +3397,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { {/* Snooze banner */} {snoozedThreads.has(latestEmail.threadId) && - currentAccountId && + selectedAccountId && (() => { const snoozeInfo = snoozedThreads.get(latestEmail.threadId); return snoozeInfo ? ( @@ -3431,7 +3422,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) {
@@ -745,6 +820,11 @@ export function EmailList() { isChecked={isChecked} isMultiSelectActive={isMultiSelectActive} density={inboxDensity} + accountLabel={ + showUnifiedAccountLabels + ? accountLabels.get(thread.latestEmail.accountId) + : undefined + } onClick={(e) => handleThreadClick(thread, e)} onCheckboxChange={() => handleCheckboxToggle(thread.threadId)} snoozeInfo={isSnoozedView ? snoozedThreads.get(thread.threadId) : undefined} diff --git a/src/renderer/components/EmailRow.tsx b/src/renderer/components/EmailRow.tsx index b6343e8f..97b00adf 100644 --- a/src/renderer/components/EmailRow.tsx +++ b/src/renderer/components/EmailRow.tsx @@ -9,6 +9,7 @@ interface EmailRowProps { isChecked: boolean; isMultiSelectActive: boolean; density: InboxDensity; + accountLabel?: string; onClick: (e: React.MouseEvent) => void; onCheckboxChange: () => void; snoozeInfo?: SnoozedEmail; @@ -120,6 +121,7 @@ export const EmailRow = memo( isChecked, isMultiSelectActive, density, + accountLabel, onClick, onCheckboxChange, snoozeInfo, @@ -205,6 +207,17 @@ export const EmailRow = memo( {senderName} + {accountLabel && ( + + {accountLabel} + + )} + {/* Priority label */} {priorityLabel && ( s.viewMode); + const composeState = useAppStore((s) => s.composeState); + const isSearchOpen = useAppStore((s) => s.isSearchOpen); + const isCommandPaletteOpen = useAppStore((s) => s.isCommandPaletteOpen); + const activeSearchQuery = useAppStore((s) => s.activeSearchQuery); + const selectedThreadIds = useAppStore((s) => s.selectedThreadIds); // Don't show hints when search or command palette is open if (isSearchOpen || isCommandPaletteOpen) { diff --git a/src/renderer/components/OfflineBanner.tsx b/src/renderer/components/OfflineBanner.tsx index fd71a262..2a983625 100644 --- a/src/renderer/components/OfflineBanner.tsx +++ b/src/renderer/components/OfflineBanner.tsx @@ -1,7 +1,8 @@ import { useAppStore } from "../store"; export function OfflineBanner() { - const { isOnline, outboxStats } = useAppStore(); + const isOnline = useAppStore((s) => s.isOnline); + const outboxStats = useAppStore((s) => s.outboxStats); // Don't show if online if (isOnline) { diff --git a/src/renderer/components/SearchBar.tsx b/src/renderer/components/SearchBar.tsx index 1a7079d8..1fce42a2 100644 --- a/src/renderer/components/SearchBar.tsx +++ b/src/renderer/components/SearchBar.tsx @@ -28,7 +28,7 @@ declare global { emails: { search: ( query: string, - accountId: string, + accountId?: string, maxResults?: number, ) => Promise>; searchRemote: ( @@ -60,16 +60,14 @@ export function SearchBar({ isOpen, onClose }: SearchBarProps) { const [hasNavigated, setHasNavigated] = useState(false); // Track if user used arrow keys const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); - const { - setSelectedEmailId, - currentAccountId, - setActiveSearch, - setViewMode, - isOnline, - setRemoteSearchResults, - setRemoteSearchError, - setCurrentSplitId, - } = useAppStore(); + const setSelectedEmailId = useAppStore((s) => s.setSelectedEmailId); + const currentAccountId = useAppStore((s) => s.currentAccountId); + const setActiveSearch = useAppStore((s) => s.setActiveSearch); + const setViewMode = useAppStore((s) => s.setViewMode); + const isOnline = useAppStore((s) => s.isOnline); + const setRemoteSearchResults = useAppStore((s) => s.setRemoteSearchResults); + const setRemoteSearchError = useAppStore((s) => s.setRemoteSearchError); + const setCurrentSplitId = useAppStore((s) => s.setCurrentSplitId); // The "search all mail" affordance is at index === results.length const searchAllMailIndex = results.length; @@ -123,7 +121,7 @@ export function SearchBar({ isOpen, onClose }: SearchBarProps) { // Perform full Gmail search and show results (local + remote in parallel) const performFullSearch = useCallback(() => { - if (!query.trim() || !currentAccountId) return; + if (!query.trim()) return; // Special handling for "in:draft" / "in:drafts" — switch to drafts view instead of searching const trimmed = query.trim().toLowerCase(); @@ -141,7 +139,7 @@ export function SearchBar({ isOpen, onClose }: SearchBarProps) { // Fire local search — results stream into the store when ready window.api.emails - .search(query, currentAccountId, 500) + .search(query, currentAccountId ?? undefined, 500) .then((localResponse: IpcResponse) => { if (useAppStore.getState().activeSearchQuery !== query) return; if (localResponse.success && localResponse.data) { @@ -153,7 +151,7 @@ export function SearchBar({ isOpen, onClose }: SearchBarProps) { }); // Fire remote search (slow) — results stream into the store when ready - if (isOnline) { + if (isOnline && currentAccountId) { window.api.emails .searchRemote(query, currentAccountId, 500) .then( diff --git a/src/renderer/components/SnippetsEditor.tsx b/src/renderer/components/SnippetsEditor.tsx index 22357c05..52a17bf8 100644 --- a/src/renderer/components/SnippetsEditor.tsx +++ b/src/renderer/components/SnippetsEditor.tsx @@ -6,7 +6,10 @@ type SuperhumanAccount = { email: string; snippetCount: number }; type ImportResult = { imported: number; warnings: string[] }; export function SnippetsEditor() { - const { snippets: allSnippets, setSnippets, currentAccountId, accounts } = useAppStore(); + const allSnippets = useAppStore((s) => s.snippets); + const setSnippets = useAppStore((s) => s.setSnippets); + const currentAccountId = useAppStore((s) => s.currentAccountId); + const accounts = useAppStore((s) => s.accounts); const [editingSnippet, setEditingSnippet] = useState(null); const [isCreating, setIsCreating] = useState(false); const [isLoading, setIsLoading] = useState(true); diff --git a/src/renderer/components/SplitConfigEditor.tsx b/src/renderer/components/SplitConfigEditor.tsx index d8d4650b..fb44cd11 100644 --- a/src/renderer/components/SplitConfigEditor.tsx +++ b/src/renderer/components/SplitConfigEditor.tsx @@ -235,7 +235,10 @@ type SuperhumanAccount = { email: string; splitCount: number }; type ImportResult = { imported: number; warnings: string[] }; export function SplitConfigEditor() { - const { splits: allSplits, setSplits, currentAccountId, accounts } = useAppStore(); + const allSplits = useAppStore((s) => s.splits); + const setSplits = useAppStore((s) => s.setSplits); + const currentAccountId = useAppStore((s) => s.currentAccountId); + const accounts = useAppStore((s) => s.accounts); const [editingSplit, setEditingSplit] = useState(null); const [isCreating, setIsCreating] = useState(false); const [isLoading, setIsLoading] = useState(true); diff --git a/src/renderer/hooks/useBatchActions.ts b/src/renderer/hooks/useBatchActions.ts index 220c704a..ef09c2b4 100644 --- a/src/renderer/hooks/useBatchActions.ts +++ b/src/renderer/hooks/useBatchActions.ts @@ -7,104 +7,100 @@ import { trackEvent } from "../services/posthog"; * Safe to call from event handlers, useCallback bodies, or keyboard shortcuts. */ -export function batchArchive() { - const { - selectedThreadIds, - emails, - removeEmails, - clearSelectedThreads, - addUndoAction, - currentAccountId, - } = useAppStore.getState(); - if (!currentAccountId || selectedThreadIds.size === 0) return; +function groupSelectedThreadsByAccount( + threadIds: Iterable, + emails: DashboardEmail[], +): Map { + const grouped = new Map(); - const threadIds = Array.from(selectedThreadIds); - const allEmailIds: string[] = []; - const emailsByThread = new Map(); for (const threadId of threadIds) { - const threadEmails = emails.filter((e) => e.threadId === threadId); - emailsByThread.set(threadId, threadEmails); - for (const email of threadEmails) { - allEmailIds.push(email.id); - } + const threadEmails = emails.filter((email) => email.threadId === threadId); + if (threadEmails.length === 0) continue; + + const accountId = threadEmails[0].accountId; + const existing = grouped.get(accountId) ?? { threadIds: [], emails: [] }; + existing.threadIds.push(threadId); + existing.emails.push(...threadEmails); + grouped.set(accountId, existing); } + return grouped; +} + +export function batchArchive() { + const { selectedThreadIds, emails, removeEmails, clearSelectedThreads, addUndoAction } = + useAppStore.getState(); + if (selectedThreadIds.size === 0) return; + + const threadIds = Array.from(selectedThreadIds); + const groupedByAccount = groupSelectedThreadsByAccount(threadIds, emails); + const allEmailIds = Array.from(groupedByAccount.values()).flatMap((group) => + group.emails.map((email) => email.id), + ); + // Optimistic UI: remove all emails from selected threads - const allEmails = threadIds.flatMap((tid) => emailsByThread.get(tid) || []); removeEmails(allEmailIds); clearSelectedThreads(); - // Queue a single undo action for all threads - addUndoAction({ - id: `archive-batch-${Date.now()}`, - type: "archive", - threadCount: threadIds.length, - accountId: currentAccountId, - emails: [...allEmails], - scheduledAt: Date.now(), - delayMs: 5000, - }); + for (const [accountId, group] of groupedByAccount) { + addUndoAction({ + id: `archive-batch-${accountId}-${Date.now()}`, + type: "archive", + threadCount: group.threadIds.length, + accountId, + emails: [...group.emails], + scheduledAt: Date.now(), + delayMs: 5000, + }); + } // Tracks intent — user may still undo within 5 s trackEvent("email_archived", { thread_count: threadIds.length, source: "batch" }); } export function batchTrash() { - const { - selectedThreadIds, - emails, - removeEmails, - clearSelectedThreads, - addUndoAction, - currentAccountId, - } = useAppStore.getState(); - if (!currentAccountId || selectedThreadIds.size === 0) return; + const { selectedThreadIds, emails, removeEmails, clearSelectedThreads, addUndoAction } = + useAppStore.getState(); + if (selectedThreadIds.size === 0) return; const threadIds = Array.from(selectedThreadIds); - const allEmailIds: string[] = []; - const emailsByThread = new Map(); - for (const threadId of threadIds) { - const threadEmails = emails.filter((e) => e.threadId === threadId); - emailsByThread.set(threadId, threadEmails); - for (const email of threadEmails) { - allEmailIds.push(email.id); - } - } + const groupedByAccount = groupSelectedThreadsByAccount(threadIds, emails); + const allEmailIds = Array.from(groupedByAccount.values()).flatMap((group) => + group.emails.map((email) => email.id), + ); - const allEmails = threadIds.flatMap((tid) => emailsByThread.get(tid) || []); removeEmails(allEmailIds); clearSelectedThreads(); - // Queue a single undo action for all threads - addUndoAction({ - id: `trash-batch-${Date.now()}`, - type: "trash", - threadCount: threadIds.length, - accountId: currentAccountId, - emails: [...allEmails], - scheduledAt: Date.now(), - delayMs: 5000, - }); + for (const [accountId, group] of groupedByAccount) { + addUndoAction({ + id: `trash-batch-${accountId}-${Date.now()}`, + type: "trash", + threadCount: group.threadIds.length, + accountId, + emails: [...group.emails], + scheduledAt: Date.now(), + delayMs: 5000, + }); + } // Tracks intent — user may still undo within 5 s trackEvent("email_trashed", { thread_count: threadIds.length, source: "batch" }); } export function batchToggleStar() { - const { - selectedThreadIds, - emails, - clearSelectedThreads, - updateEmail, - addUndoAction, - currentAccountId, - } = useAppStore.getState(); - if (!currentAccountId || selectedThreadIds.size === 0) return; + const { selectedThreadIds, emails, clearSelectedThreads, updateEmail, addUndoAction } = + useAppStore.getState(); + if (selectedThreadIds.size === 0) return; // Group emails by thread for the selected threads - const selectedThreadEmails: Array<{ threadId: string; emails: typeof emails }> = []; - for (const threadId of selectedThreadIds) { - const threadEmails = emails.filter((e) => e.threadId === threadId); - selectedThreadEmails.push({ threadId, emails: threadEmails }); - } + const selectedThreadEmails = Array.from( + groupSelectedThreadsByAccount(selectedThreadIds, emails), + ).flatMap(([accountId, group]) => + group.threadIds.map((threadId) => ({ + threadId, + accountId, + emails: group.emails.filter((email) => email.threadId === threadId), + })), + ); // If any thread is unstarred, star all; otherwise unstar all const anyUnstarred = selectedThreadEmails.some( @@ -140,16 +136,33 @@ export function batchToggleStar() { if (changedEmails.length > 0) { const actionType = anyUnstarred ? "star" : "unstar"; - addUndoAction({ - id: `${actionType}-batch-${Date.now()}`, - type: actionType, - threadCount: selectedThreadIds.size, - accountId: currentAccountId, - emails: changedEmails, - scheduledAt: Date.now(), - delayMs: 5000, - previousLabels, - }); + const changedByAccount = new Map(); + for (const email of changedEmails) { + const existing = changedByAccount.get(email.accountId) ?? []; + existing.push(email); + changedByAccount.set(email.accountId, existing); + } + + for (const [accountId, accountEmails] of changedByAccount) { + const accountPreviousLabels = Object.fromEntries( + accountEmails + .map((email) => { + const labels = previousLabels[email.id]; + return labels ? ([email.id, labels] as const) : null; + }) + .filter((entry): entry is readonly [string, string[]] => entry !== null), + ); + addUndoAction({ + id: `${actionType}-batch-${accountId}-${Date.now()}`, + type: actionType, + threadCount: new Set(accountEmails.map((email) => email.threadId)).size, + accountId, + emails: accountEmails, + scheduledAt: Date.now(), + delayMs: 5000, + previousLabels: accountPreviousLabels, + }); + } const changedThreadCount = new Set(changedEmails.map((e) => e.threadId)).size; trackEvent(anyUnstarred ? "email_starred" : "email_unstarred", { thread_count: changedThreadCount, @@ -158,15 +171,9 @@ export function batchToggleStar() { } export function batchMarkUnread() { - const { - selectedThreadIds, - emails, - clearSelectedThreads, - updateEmail, - addUndoAction, - currentAccountId, - } = useAppStore.getState(); - if (!currentAccountId || selectedThreadIds.size === 0) return; + const { selectedThreadIds, emails, clearSelectedThreads, updateEmail, addUndoAction } = + useAppStore.getState(); + if (selectedThreadIds.size === 0) return; const changedEmails: DashboardEmail[] = []; const previousLabels: Record = {}; @@ -189,16 +196,33 @@ export function batchMarkUnread() { clearSelectedThreads(); if (changedEmails.length > 0) { - addUndoAction({ - id: `mark-unread-batch-${Date.now()}`, - type: "mark-unread", - threadCount: changedEmails.length, - accountId: currentAccountId, - emails: changedEmails, - scheduledAt: Date.now(), - delayMs: 5000, - previousLabels, - }); + const changedByAccount = new Map(); + for (const email of changedEmails) { + const existing = changedByAccount.get(email.accountId) ?? []; + existing.push(email); + changedByAccount.set(email.accountId, existing); + } + + for (const [accountId, accountEmails] of changedByAccount) { + const accountPreviousLabels = Object.fromEntries( + accountEmails + .map((email) => { + const labels = previousLabels[email.id]; + return labels ? ([email.id, labels] as const) : null; + }) + .filter((entry): entry is readonly [string, string[]] => entry !== null), + ); + addUndoAction({ + id: `mark-unread-batch-${accountId}-${Date.now()}`, + type: "mark-unread", + threadCount: accountEmails.length, + accountId, + emails: accountEmails, + scheduledAt: Date.now(), + delayMs: 5000, + previousLabels: accountPreviousLabels, + }); + } trackEvent("email_marked_unread", { thread_count: new Set(changedEmails.map((e) => e.threadId)).size, }); diff --git a/src/renderer/hooks/useEmails.ts b/src/renderer/hooks/useEmails.ts index 8a7c6a83..92e0009b 100644 --- a/src/renderer/hooks/useEmails.ts +++ b/src/renderer/hooks/useEmails.ts @@ -3,13 +3,9 @@ import { useAppStore } from "../store"; export function useEmails() { const _queryClient = useQueryClient(); - const { - setEmails, - setLoading: _setLoading, - setError: _setError, - updateEmail, - currentAccountId, - } = useAppStore(); + const setEmails = useAppStore((s) => s.setEmails); + const updateEmail = useAppStore((s) => s.updateEmail); + const currentAccountId = useAppStore((s) => s.currentAccountId); const fetchEmailsQuery = useQuery({ queryKey: ["emails", currentAccountId], diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 913f6bec..8d9cc0fd 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -144,6 +144,15 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) state.currentSplitId === "__drafts__" ? currentThreads.filter((t) => t.draft && t.draft.body) : currentThreads; + const selectedEmail = emails.find((email) => email.id === selectedEmailId); + const selectedAccountId = selectedEmail?.accountId ?? currentAccountId; + const currentUserEmailLookup = currentAccountId + ? accounts.find((account) => account.id === currentAccountId)?.email + : new Map( + accounts + .map((account) => [account.id, account.email] as const) + .filter((entry) => entry[1].length > 0), + ); // Always allow Escape to close modals or go back in view modes if (e.key === "Escape") { @@ -445,11 +454,12 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) }; // --- Helper: merge+dedup+thread search results (same order as rendered list) --- - const currentUserEmail = accounts.find( - (a: { id: string }) => a.id === currentAccountId, - )?.email; const getSearchThreads = () => - mergeAndThreadSearchResults(activeSearchResults, remoteSearchResults, currentUserEmail); + mergeAndThreadSearchResults( + activeSearchResults, + remoteSearchResults, + currentUserEmailLookup, + ); // --- Helper: navigate search results up/down (by thread) --- const navigateSearchResults = (direction: "up" | "down") => { @@ -473,16 +483,24 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) // --- Helper: get thread emails, falling back to search results if not in global store --- const getThreadEmails = (threadId: string) => { - const storeEmails = emails.filter((item) => item.threadId === threadId); + const storeEmails = emails.filter( + (item) => + item.threadId === threadId && + (selectedAccountId ? item.accountId === selectedAccountId : true), + ); if (storeEmails.length > 0) return storeEmails; // Fallback: thread may only exist in search results, not yet in the global store - const searchThread = getSearchThreads().find((t) => t.threadId === threadId); + const searchThread = getSearchThreads().find( + (t) => + t.threadId === threadId && + (selectedAccountId ? t.latestEmail.accountId === selectedAccountId : true), + ); return searchThread?.emails ?? []; }; // --- Helper: archive selected thread (all messages) --- const archiveSelected = () => { - if (!selectedEmailId || !selectedThreadId || !currentAccountId) return; + if (!selectedEmailId || !selectedThreadId || !selectedAccountId) return; // Collect ALL emails in the thread for optimistic removal const threadEmails = getThreadEmails(selectedThreadId); @@ -536,7 +554,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `archive-${selectedThreadId}-${Date.now()}`, type: "archive", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [...threadEmails], scheduledAt: Date.now(), delayMs: 5000, @@ -549,7 +567,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) // --- Helper: trash selected thread --- const trashSelected = () => { - if (!selectedEmailId || !selectedThreadId || !currentAccountId) return; + if (!selectedEmailId || !selectedThreadId || !selectedAccountId) return; const threadEmails = getThreadEmails(selectedThreadId); const threadEmailIds = threadEmails.map((item) => item.id); @@ -600,7 +618,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `trash-${selectedThreadId}-${Date.now()}`, type: "trash", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [...threadEmails], scheduledAt: Date.now(), delayMs: 5000, @@ -611,9 +629,11 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) // --- Helper: mark selected thread as unread --- const markSelectedUnread = () => { - if (!selectedThreadId || !currentAccountId) return; + if (!selectedThreadId || !selectedAccountId) return; - const threadEmails = emails.filter((item) => item.threadId === selectedThreadId); + const threadEmails = emails.filter( + (item) => item.threadId === selectedThreadId && item.accountId === selectedAccountId, + ); if (threadEmails.length === 0) return; const latestEmail = threadEmails.reduce((a, b) => @@ -630,7 +650,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `mark-unread-${selectedThreadId}-${Date.now()}`, type: "mark-unread", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [latestEmail], scheduledAt: Date.now(), delayMs: 5000, @@ -959,7 +979,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) // Shift+I: mark as read and return to list (Gmail only) case "I": if (isGmail && e.shiftKey) { - if (selectedThreadId && currentAccountId) { + if (selectedThreadId) { e.preventDefault(); markThreadAsRead(selectedThreadId); if (viewMode === "full") { @@ -974,9 +994,9 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) if (isMultiSelect) { e.preventDefault(); batchToggleStar(); - } else if (isGmail && selectedThreadId && currentAccountId) { + } else if (isGmail && selectedThreadId && selectedAccountId) { e.preventDefault(); - const threadEmails = emails.filter((item) => item.threadId === selectedThreadId); + const threadEmails = getThreadEmails(selectedThreadId); if (threadEmails.length === 0) break; const latestEmail = threadEmails.reduce((a, b) => new Date(a.date).getTime() >= new Date(b.date).getTime() ? a : b, @@ -1000,7 +1020,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `unstar-${selectedThreadId}-${Date.now()}`, type: "unstar", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: starredEmails, scheduledAt: Date.now(), delayMs: 5000, @@ -1016,7 +1036,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `star-${selectedThreadId}-${Date.now()}`, type: "star", threadCount: 1, - accountId: currentAccountId, + accountId: selectedAccountId, emails: [latestEmail], scheduledAt: Date.now(), delayMs: 5000, @@ -1077,9 +1097,15 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) // Shift+N: force refresh/sync current account (Gmail only) case "N": - if (isGmail && e.shiftKey && currentAccountId) { + if (isGmail && e.shiftKey) { e.preventDefault(); - window.api.sync.now(currentAccountId).catch(console.error); + if (currentAccountId) { + window.api.sync.now(currentAccountId).catch(console.error); + } else { + accounts.forEach((account) => { + window.api.sync.now(account.id).catch(console.error); + }); + } } break; diff --git a/src/renderer/hooks/useSyncBuffer.ts b/src/renderer/hooks/useSyncBuffer.ts index 36e05986..1b099975 100644 --- a/src/renderer/hooks/useSyncBuffer.ts +++ b/src/renderer/hooks/useSyncBuffer.ts @@ -130,6 +130,28 @@ function hasPending(): boolean { return pendingAdds.length > 0 || pendingRemoveIds.length > 0 || pendingUpdates.size > 0; } +function applySparseUpdates( + emails: DashboardEmail[], + updates: ReadonlyMap>, +): DashboardEmail[] { + if (updates.size === 0) return emails; + + let nextEmails: DashboardEmail[] | null = null; + + for (let index = 0; index < emails.length; index += 1) { + const changes = updates.get(emails[index].id); + if (!changes) continue; + + if (nextEmails === null) { + nextEmails = emails.slice(); + } + + nextEmails[index] = { ...emails[index], ...changes }; + } + + return nextEmails ?? emails; +} + function flush(): void { flushHandle = null; @@ -168,10 +190,7 @@ function flush(): void { // 2. In-place updates (label changes, analysis, etc.) if (updates.size > 0) { - emails = emails.map((email) => { - const changes = updates.get(email.id); - return changes ? { ...email, ...changes } : email; - }); + emails = applySparseUpdates(emails, updates); } // 3. Additions — deduplicate against current store AND pending removals @@ -232,10 +251,7 @@ function flush(): void { // Apply in-place merges for re-emitted emails if (reEmitUpdates.size > 0) { - emails = emails.map((email) => { - const changes = reEmitUpdates.get(email.id); - return changes ? { ...email, ...changes } : email; - }); + emails = applySparseUpdates(emails, reEmitUpdates); } if (brandNew.length > 0) { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7096cec1..437715c3 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { create } from "zustand"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { clearPendingLabelUpdates } from "../hooks-bridge"; import { applyOptimisticReads, addOptimisticReads } from "../optimistic-reads"; import type { @@ -81,6 +82,44 @@ export type EmailThread = { displaySender: string; }; +type CurrentUserEmailLookup = string | ReadonlyMap | undefined; + +function areThreadingEmailsEqual(prev: DashboardEmail[], next: DashboardEmail[]): boolean { + if (prev === next) return true; + if (prev.length !== next.length) return false; + + for (let index = 0; index < prev.length; index += 1) { + const previousEmail = prev[index]; + const nextEmail = next[index]; + + if (previousEmail === nextEmail) continue; + + if ( + previousEmail.id !== nextEmail.id || + previousEmail.threadId !== nextEmail.threadId || + previousEmail.accountId !== nextEmail.accountId || + previousEmail.subject !== nextEmail.subject || + previousEmail.from !== nextEmail.from || + previousEmail.to !== nextEmail.to || + previousEmail.cc !== nextEmail.cc || + previousEmail.bcc !== nextEmail.bcc || + previousEmail.date !== nextEmail.date || + previousEmail.snippet !== nextEmail.snippet || + previousEmail.labelIds !== nextEmail.labelIds || + previousEmail.isUnread !== nextEmail.isUnread || + previousEmail.attachments !== nextEmail.attachments || + previousEmail.messageId !== nextEmail.messageId || + previousEmail.inReplyTo !== nextEmail.inReplyTo || + previousEmail.analysis !== nextEmail.analysis || + previousEmail.draft !== nextEmail.draft + ) { + return false; + } + } + + return true; +} + // Account representation export type Account = { id: string; @@ -346,7 +385,7 @@ interface AppState { setShowSettings: (show: boolean, initialTab?: SettingsTab) => void; updateEmail: (id: string, updates: Partial) => void; // Multi-account actions - setAccounts: (accounts: Account[]) => void; + setAccounts: (accounts: Account[], currentAccountId?: string | null) => void; addAccount: (account: Account) => void; removeAccount: (accountId: string) => void; setCurrentAccountId: (accountId: string | null) => void; @@ -805,19 +844,37 @@ export const useAppStore = create((set, get) => ({ highlightMemoryIds: show ? get().highlightMemoryIds : [], }), updateEmail: (id, updates) => - set((state) => ({ - emails: state.emails.map((email) => (email.id === id ? { ...email, ...updates } : email)), - sentEmails: state.sentEmails.map((email) => - email.id === id ? { ...email, ...updates } : email, - ), - })), + set((state) => { + const nextState: Partial = {}; + + const emailIndex = state.emails.findIndex((email) => email.id === id); + if (emailIndex !== -1) { + const emails = state.emails.slice(); + emails[emailIndex] = { ...emails[emailIndex], ...updates }; + nextState.emails = emails; + } + + const sentEmailIndex = state.sentEmails.findIndex((email) => email.id === id); + if (sentEmailIndex !== -1) { + const sentEmails = state.sentEmails.slice(); + sentEmails[sentEmailIndex] = { ...sentEmails[sentEmailIndex], ...updates }; + nextState.sentEmails = sentEmails; + } + + return Object.keys(nextState).length > 0 ? nextState : state; + }), // Multi-account actions - setAccounts: (accounts) => - set({ - accounts, - // Set current to primary or first account if not set - currentAccountId: - get().currentAccountId || accounts.find((a) => a.isPrimary)?.id || accounts[0]?.id || null, + setAccounts: (accounts, currentAccountId) => + set((state) => { + let nextCurrentAccountId = + currentAccountId !== undefined ? currentAccountId : state.currentAccountId; + if ( + nextCurrentAccountId !== null && + !accounts.some((account) => account.id === nextCurrentAccountId) + ) { + nextCurrentAccountId = accounts.find((a) => a.isPrimary)?.id || accounts[0]?.id || null; + } + return { accounts, currentAccountId: nextCurrentAccountId }; }), addAccount: (account) => set((state) => { @@ -1586,10 +1643,9 @@ export const useAppStore = create((set, get) => ({ markThreadAsRead: (threadId) => { const state = get(); - const accountId = state.currentAccountId; - if (!accountId) return; - const threadEmails = state.emails.filter((e) => e.threadId === threadId); + const accountId = threadEmails[0]?.accountId ?? state.currentAccountId; + if (!accountId) return; const unreadEmails = threadEmails.filter((e) => e.labelIds?.includes("UNREAD")); if (unreadEmails.length === 0) return; @@ -1684,16 +1740,27 @@ export function getAppStateSnapshot(): Record { } // Check if an email is sent by the user (not received) -function isSentEmail(email: DashboardEmail, currentUserEmail?: string): boolean { +function resolveCurrentUserEmail( + email: DashboardEmail, + currentUserEmail: CurrentUserEmailLookup, +): string | undefined { + if (typeof currentUserEmail === "string") { + return currentUserEmail; + } + return currentUserEmail?.get(email.accountId); +} + +function isSentEmail(email: DashboardEmail, currentUserEmail?: CurrentUserEmailLookup): boolean { // Check labelIds first (most reliable) if (email.labelIds?.includes("SENT")) { return true; } // Fall back to checking the from field - if (!currentUserEmail) return false; + const resolvedCurrentUserEmail = resolveCurrentUserEmail(email, currentUserEmail); + if (!resolvedCurrentUserEmail) return false; const fromLower = email.from.toLowerCase(); - const userEmailLower = currentUserEmail.toLowerCase(); + const userEmailLower = resolvedCurrentUserEmail.toLowerCase(); // Extract email from "Name " format if present const emailMatch = fromLower.match(/<([^>]+)>/) || [null, fromLower]; const fromEmail = emailMatch[1] || fromLower; @@ -1701,7 +1768,10 @@ function isSentEmail(email: DashboardEmail, currentUserEmail?: string): boolean } // Helper to group emails by thread -export function groupByThread(emails: DashboardEmail[], currentUserEmail?: string): EmailThread[] { +export function groupByThread( + emails: DashboardEmail[], + currentUserEmail?: CurrentUserEmailLookup, +): EmailThread[] { const threadMap = new Map(); // Pre-compute timestamps once to avoid creating Date objects in every sort @@ -1795,7 +1865,11 @@ const REPLY_GRACE_PERIOD_MS = 3 * 60 * 1000; // 3 minutes // Selector for threaded and filtered emails export function useThreadedEmails() { - const emails = useAppStore((state) => state.emails); + const emails = useStoreWithEqualityFn( + useAppStore, + (state) => state.emails, + areThreadingEmailsEqual, + ); const currentAccountId = useAppStore((state) => state.currentAccountId); const accounts = useAppStore((state) => state.accounts); const snoozedThreadIds = useAppStore((state) => state.snoozedThreadIds); @@ -1804,6 +1878,15 @@ export function useThreadedEmails() { // Get current user's email for sent detection const currentAccount = accounts.find((a) => a.id === currentAccountId); const currentUserEmail = currentAccount?.email; + const currentUserEmailsByAccount = useMemo( + () => + new Map( + accounts + .map((account) => [account.id, account.email] as const) + .filter((entry) => entry[1].length > 0), + ), + [accounts], + ); // Memoize the expensive thread computation. j/k navigation only changes // selectedEmailId — none of these deps change, so the memo short-circuits @@ -1828,7 +1911,8 @@ export function useThreadedEmails() { // Then filter out sent-only threads — threads where no email has the INBOX label. // Sent emails within inbox threads are kept (for conversation context), but threads // consisting solely of sent emails belong in the Sent view, not the inbox. - const allThreads = groupByThread(accountEmails, currentUserEmail).filter((t) => + const userEmailLookup = currentAccountId ? currentUserEmail : currentUserEmailsByAccount; + const allThreads = groupByThread(accountEmails, userEmailLookup).filter((t) => t.emails.some((e) => !e.labelIds || e.labelIds.includes("INBOX")), ); @@ -1886,7 +1970,14 @@ export function useThreadedEmails() { snoozed, snoozedCount: snoozed.length, }; - }, [emails, currentAccountId, currentUserEmail, snoozedThreadIds, recentlyRepliedThreadIds]); + }, [ + emails, + currentAccountId, + currentUserEmail, + currentUserEmailsByAccount, + snoozedThreadIds, + recentlyRepliedThreadIds, + ]); } function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean { @@ -1905,6 +1996,15 @@ export function useSplitFilteredThreads() { const recentlyUnsnoozedThreadIds = useAppStore((state) => state.recentlyUnsnoozedThreadIds); const unsnoozedReturnTimes = useAppStore((state) => state.unsnoozedReturnTimes); const sentEmails = useAppStore((state) => state.sentEmails); + const currentUserEmailsByAccount = useMemo( + () => + new Map( + accounts + .map((account) => [account.id, account.email] as const) + .filter((entry) => entry[1].length > 0), + ), + [accounts], + ); return useMemo(() => { // Filter splits for current account @@ -1942,7 +2042,8 @@ export function useSplitFilteredThreads() { const sentAccountEmails = currentAccountId ? sentEmails.filter((e) => e.accountId === currentAccountId) : sentEmails; - const sentThreads = groupByThread(sentAccountEmails, currentUserEmail).sort( + const userEmailLookup = currentAccountId ? currentUserEmail : currentUserEmailsByAccount; + const sentThreads = groupByThread(sentAccountEmails, userEmailLookup).sort( (a, b) => new Date(b.latestEmail.date).getTime() - new Date(a.latestEmail.date).getTime(), ); diff --git a/src/renderer/utils/searchResults.ts b/src/renderer/utils/searchResults.ts index b6a1d8b8..2fc02520 100644 --- a/src/renderer/utils/searchResults.ts +++ b/src/renderer/utils/searchResults.ts @@ -39,7 +39,7 @@ export function mergeAndSortSearchResults( export function mergeAndThreadSearchResults( localResults: readonly DashboardEmail[], remoteResults: readonly DashboardEmail[], - currentUserEmail?: string, + currentUserEmail?: string | ReadonlyMap, ): EmailThread[] { const merged = mergeAndSortSearchResults(localResults, remoteResults); const threads = groupByThread(merged, currentUserEmail);