diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fcc41ccc..86f3d6ce 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useAppStore, useThreadedEmails, type Account, type SyncStatus, type PrefetchProgress, type BackgroundSyncProgress } from "./store"; +import { useAppStore, useThreadedEmails, getAccountColor, type Account, type SyncStatus, type PrefetchProgress, type BackgroundSyncProgress } from "./store"; import { EmailList } from "./components/EmailList"; import { EmailDetail } from "./components/EmailDetail"; import { EmailPreviewSidebar } from "./components/EmailPreviewSidebar"; @@ -170,7 +170,13 @@ function SearchResultsView() { remoteSearchLoadingMore, } = useAppStore(); - const currentUserEmail = accounts.find(a => a.id === currentAccountId)?.email; + const searchUserEmails = useMemo(() => { + if (currentAccountId) { + const email = accounts.find(a => a.id === currentAccountId)?.email; + return email ? new Set([email]) : new Set(); + } + return new Set(accounts.map(a => a.email)); + }, [currentAccountId, accounts]); const scrollContainerRef = useRef(null); const sentinelRef = useRef(null); @@ -259,11 +265,13 @@ 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, searchUserEmails), + [activeSearchResults, remoteSearchResults, searchUserEmails], ); - const hasMoreResults = !!remoteSearchNextPageToken && remoteSearchStatus === "complete"; + // "Load more" only works when a single account is selected — in All accounts + // mode the stored nextPageToken is ambiguous (could be from any account). + const hasMoreResults = !!currentAccountId && !!remoteSearchNextPageToken && remoteSearchStatus === "complete"; return (
@@ -603,11 +611,23 @@ export default function App() { })); setAccounts(fullAccounts); - // Set current account to primary or first available + // Respect defaultAccountView config for startup account selection + const configResult = await window.api.settings.get(); + const defaultView = configResult.success ? configResult.data.defaultAccountView ?? "all" : "all"; + + if (fullAccounts.length > 1) { + if (defaultView === "primary") { + const primary = fullAccounts.find(a => a.isPrimary) || fullAccounts[0]; + setCurrentAccountId(primary.id); + } else { + setCurrentAccountId(null); + } + } else if (fullAccounts.length === 1) { + setCurrentAccountId(fullAccounts[0].id); + } + 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, }); @@ -1161,8 +1181,16 @@ export default function App() { // Reload sent emails too await reloadSentEmailsForAccount(currentAccountId); } else { - // Fallback to legacy fetch for default account - await fetchEmails(); + // All accounts mode: sync every account and reload emails + await Promise.all(accounts.map(async (account) => { + await window.api.sync.now(account.id); + const result = await window.api.sync.getEmails(account.id); + if (result.success && result.data) { + addEmails(result.data); + prefetchEmailBodies(result.data.map((e: DashboardEmail) => e.id)).catch(console.error); + } + await reloadSentEmailsForAccount(account.id); + })); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch emails"); @@ -1230,9 +1258,12 @@ export default function App() { // Get current account and its sync status const currentAccount = accounts.find((a) => a.id === currentAccountId); + const isAllAccountsMode = currentAccountId === null && accounts.length > 0; const currentSyncStatus = currentAccountId ? getSyncStatus(currentAccountId) : "idle"; - const isSyncing = currentSyncStatus === "syncing"; + const isAnySyncing = isAllAccountsMode && accounts.some((a) => getSyncStatus(a.id) === "syncing"); + const isSyncing = currentSyncStatus === "syncing" || isAnySyncing; const isCurrentAccountExpired = currentAccountId != null && expiredAccountIds.has(currentAccountId); + const isAnyAccountExpired = isAllAccountsMode && accounts.some((a) => expiredAccountIds.has(a.id)); // Build list of expired accounts with their email addresses for the banner const expiredAccounts = accounts.filter((a) => expiredAccountIds.has(a.id)); @@ -1269,6 +1300,32 @@ export default function App() { window.api.sync.now(accountId).catch(console.error); }; + const handleAllAccountsClick = () => { + setCurrentAccountId(null); + setAccountMenuOpen(false); + trackEvent("account_switched", { account_count: accounts.length, mode: "all" }); + + // Ensure all accounts have their emails loaded + const storeState = useAppStore.getState(); + for (const account of accounts) { + if (!storeState.emails.some((e) => e.accountId === account.id)) { + window.api.sync.getEmails(account.id).then((result: IpcResponse) => { + if (result.success && result.data && result.data.length > 0) { + addEmails(result.data); + prefetchEmailBodies(result.data.map((e: DashboardEmail) => e.id)).catch(console.error); + } + }).catch(console.error); + } + if (!storeState.sentEmails.some((e) => e.accountId === account.id)) { + window.api.sync.getSentEmails(account.id).then((sentResult: IpcResponse) => { + if (sentResult.success && sentResult.data) { + addSentEmails(sentResult.data); + } + }).catch(console.error); + } + } + }; + const handleCancelScheduled = async (id: string) => { const result = await window.api.scheduledSend.cancel(id) as { success: boolean; @@ -1307,7 +1364,7 @@ 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"} + {isAllAccountsMode ? "All accounts" : currentAccount?.email || "Select account"} {/* Sync status indicator */} {isSyncing && ( @@ -1316,13 +1373,13 @@ export default function App() { )} - {!isSyncing && !isCurrentAccountExpired && currentSyncStatus === "idle" && ( + {!isSyncing && !isCurrentAccountExpired && !isAnyAccountExpired && (isAllAccountsMode || currentSyncStatus === "idle") && ( )} - {isCurrentAccountExpired && ( + {(isCurrentAccountExpired || isAnyAccountExpired) && ( )} - {!isCurrentAccountExpired && currentSyncStatus === "error" && ( + {!isCurrentAccountExpired && !isAnyAccountExpired && !isAllAccountsMode && currentSyncStatus === "error" && ( )} @@ -1334,6 +1391,21 @@ export default function App() { {accountMenuOpen && (
+ {accounts.length > 1 && ( + + )} {accounts.map((account) => (
{/* Snooze banner */} - {snoozedThreads.has(latestEmail.threadId) && currentAccountId && (() => { + {snoozedThreads.has(latestEmail.threadId) && effectiveAccountId && (() => { const snoozeInfo = snoozedThreads.get(latestEmail.threadId); return snoozeInfo ? (
@@ -2911,7 +2915,7 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) {
); diff --git a/src/renderer/components/EmailRow.tsx b/src/renderer/components/EmailRow.tsx index b72f2c11..f91c283d 100644 --- a/src/renderer/components/EmailRow.tsx +++ b/src/renderer/components/EmailRow.tsx @@ -3,6 +3,12 @@ import type { InboxDensity, SnoozedEmail } from "../../shared/types"; import type { EmailThread } from "../store"; import { formatSnoozeTime } from "./SnoozeMenu"; +export interface AccountBadge { + label: string; + bgClass: string; + textClass: string; +} + interface EmailRowProps { thread: EmailThread; isSelected: boolean; @@ -13,6 +19,7 @@ interface EmailRowProps { onCheckboxChange: () => void; snoozeInfo?: SnoozedEmail; returnTime?: number; // Unsnooze return time — shown instead of last message time + accountBadge?: AccountBadge; } // Density-specific style maps @@ -104,7 +111,7 @@ function getPriorityLabel(thread: EmailThread): { text: string; className: strin // Memoized so that j/k navigation only re-renders the two rows whose // isSelected changed, not every row in the list. The custom comparator // skips onClick/onCheckboxChange (always new arrow functions from the parent). -export const EmailRow = memo(function EmailRow({ thread, isSelected, isChecked, isMultiSelectActive, density, onClick, onCheckboxChange, snoozeInfo, returnTime }: EmailRowProps) { +export const EmailRow = memo(function EmailRow({ thread, isSelected, isChecked, isMultiSelectActive, density, onClick, onCheckboxChange, snoozeInfo, returnTime, accountBadge }: EmailRowProps) { const senderName = extractSenderName(thread.displaySender); const time = returnTime ? formatRelativeDate(new Date(returnTime).toISOString()) @@ -187,6 +194,19 @@ export const EmailRow = memo(function EmailRow({ thread, isSelected, isChecked,
)} + {/* Account badge (shown in "All accounts" mode) */} + {accountBadge && ( + + {accountBadge.label} + + )} + {/* Subject + Snippet (combined to use available space) */}
{ - if (!query.trim() || !currentAccountId) return; + const accountIds = currentAccountId ? [currentAccountId] : accounts.map(a => a.id); + if (!query.trim() || accountIds.length === 0) return; // Special handling for "in:draft" / "in:drafts" — switch to drafts view instead of searching const trimmed = query.trim().toLowerCase(); @@ -123,42 +125,69 @@ export function SearchBar({ isOpen, onClose }: SearchBarProps) { trackEvent("search_performed"); - // Close modal immediately and show SearchResultsView with loading state. - // setActiveSearch closes the modal, sets remoteSearchStatus: 'searching'. setActiveSearch(query, []); - // Fire local search — results stream into the store when ready - window.api.emails.search(query, currentAccountId, 500) - .then((localResponse: IpcResponse) => { - if (useAppStore.getState().activeSearchQuery !== query) return; - if (localResponse.success && localResponse.data) { - useAppStore.getState().setActiveSearchResults(localResponse.data); - } - }) - .catch((error: unknown) => { - console.error("Local search failed:", error); - }); - - // Fire remote search (slow) — results stream into the store when ready - if (isOnline) { - window.api.emails.searchRemote(query, currentAccountId, 500) - .then((response: { success: boolean; data?: { emails: DashboardEmail[]; nextPageToken?: string }; error?: string }) => { + // Fire local search for each account and merge results + for (const accountId of accountIds) { + window.api.emails.search(query, accountId, 500) + .then((localResponse: IpcResponse) => { if (useAppStore.getState().activeSearchQuery !== query) return; - if (response.success && response.data) { - setRemoteSearchResults(response.data.emails); - useAppStore.getState().setRemoteSearchNextPageToken(response.data.nextPageToken ?? null); - } else { - setRemoteSearchError(response.error || "Gmail search failed"); + if (localResponse.success && localResponse.data) { + const existing = useAppStore.getState().activeSearchResults ?? []; + const existingIds = new Set(existing.map(e => e.id)); + const newEmails = localResponse.data.filter(e => !existingIds.has(e.id)); + if (newEmails.length > 0) { + useAppStore.getState().setActiveSearchResults([...existing, ...newEmails]); + } } }) - .catch((err: unknown) => { - if (useAppStore.getState().activeSearchQuery !== query) return; - setRemoteSearchError(err instanceof Error ? err.message : "Gmail search failed"); + .catch((error: unknown) => { + console.error("Local search failed:", error); }); + } + + // Fire remote search for each account and merge results. + // Use Promise.all to ensure remoteSearchStatus transitions to "complete" + // even when all accounts return empty or fully-deduplicated results. + if (isOnline) { + const remotePromises = accountIds.map((accountId) => + window.api.emails.searchRemote(query, accountId, 500) + .then((response: { success: boolean; data?: { emails: DashboardEmail[]; nextPageToken?: string }; error?: string }) => { + if (useAppStore.getState().activeSearchQuery !== query) return; + if (response.success && response.data) { + const existing = useAppStore.getState().remoteSearchResults ?? []; + const existingIds = new Set(existing.map(e => e.id)); + const newEmails = response.data.emails.filter(e => !existingIds.has(e.id)); + if (newEmails.length > 0) { + setRemoteSearchResults([...existing, ...newEmails]); + } + // Only store nextPageToken for single-account searches — in All + // accounts mode the token is ambiguous and "load more" is disabled. + if (response.data.nextPageToken && currentAccountId) { + useAppStore.getState().setRemoteSearchNextPageToken(response.data.nextPageToken); + } + } else { + setRemoteSearchError(response.error || "Gmail search failed"); + } + }) + .catch((err: unknown) => { + if (useAppStore.getState().activeSearchQuery !== query) return; + setRemoteSearchError(err instanceof Error ? err.message : "Gmail search failed"); + }) + ); + Promise.all(remotePromises).then(() => { + // If no individual response called setRemoteSearchResults (e.g. all + // results were empty or duplicates), the status is still "searching". + // Force it to "complete" so the spinner stops. + const s = useAppStore.getState(); + if (s.activeSearchQuery === query && s.remoteSearchStatus === "searching") { + setRemoteSearchResults(s.remoteSearchResults ?? []); + } + }); } else { setRemoteSearchResults([]); } - }, [query, currentAccountId, isOnline, setActiveSearch, setRemoteSearchResults, setRemoteSearchError, setCurrentSplitId, onClose]); + }, [query, currentAccountId, accounts, isOnline, setActiveSearch, setRemoteSearchResults, setRemoteSearchError, setCurrentSplitId, onClose]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index aafd21bf..539f5db8 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1219,6 +1219,65 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { )}
+ {/* Default Inbox View — only relevant with multiple accounts */} + {accounts.length > 1 && ( +
+

Default Inbox View

+

+ Choose which view to show when the app starts. +

+
+ + +
+
+ )} + {/* Add account button */}