From 41f820a2a0d399afefacf3c545d0ac44164d83ac Mon Sep 17 00:00:00 2001 From: Hans Kristoffer Date: Mon, 30 Mar 2026 22:51:40 +0200 Subject: [PATCH 1/3] Add 'All accounts' mode & multi-account handling Introduce an "All accounts" mode (currentAccountId === null) and propagate an effectiveAccountId throughout the renderer so actions, sync, compose, search, snooze, archive-ready, batch actions and keyboard shortcuts work when multiple accounts are selected. Key changes: - App: default selection logic updated to use "All accounts" when multiple accounts exist; added account switch handler to load/prefetch emails for all accounts; compute aggregate sync/expired status and update UI indicators. - Store: add deterministic account color palette and helpers (getAccountColor, getAccountLabel) for badges. - EmailList / EmailRow: show per-account badges in All mode, merge snooze/archiveReady loads across accounts, and listen for cross-account snooze/archive events. - EmailDetail, AgentCommandPalette, CommandPalette, SearchBar, useBatchActions, useKeyboardShortcuts: derive effectiveAccountId (currentAccountId || related email.accountId) so commands, optimistic actions, compose/reply info, searches (local+remote) and batch/keyboard operations target the correct account when in All mode. - Search: run local and remote searches across all accounts and merge results deduplicating by id. These changes make the app behave correctly when viewing or operating across all accounts while preserving single-account behavior. --- src/renderer/App.tsx | 74 +++++++++++--- .../components/AgentCommandPalette.tsx | 9 +- src/renderer/components/CommandPalette.tsx | 64 +++++++------ src/renderer/components/EmailDetail.tsx | 88 +++++++++-------- src/renderer/components/EmailList.tsx | 96 ++++++++++++------- src/renderer/components/EmailRow.tsx | 25 ++++- src/renderer/components/SearchBar.tsx | 72 ++++++++------ src/renderer/hooks/useBatchActions.ts | 22 +++-- src/renderer/hooks/useKeyboardShortcuts.ts | 37 +++---- src/renderer/store/index.ts | 41 ++++++-- 10 files changed, 341 insertions(+), 187 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fcc41ccc..381cfc5e 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"; @@ -603,11 +603,15 @@ export default function App() { })); setAccounts(fullAccounts); - // Set current account to primary or first available + // Default to "All accounts" when multiple accounts, single account otherwise + if (fullAccounts.length > 1) { + 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, }); @@ -1230,9 +1234,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 +1276,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 +1340,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 +1349,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 +1367,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 +2909,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,56 @@ 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 + if (isOnline) { + for (const accountId of accountIds) { + 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]); + } + if (response.data.nextPageToken) { + 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"); + }); + } } 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/hooks/useBatchActions.ts b/src/renderer/hooks/useBatchActions.ts index 97646fc7..684436f6 100644 --- a/src/renderer/hooks/useBatchActions.ts +++ b/src/renderer/hooks/useBatchActions.ts @@ -9,7 +9,8 @@ import { trackEvent } from "../services/posthog"; export function batchArchive() { const { selectedThreadIds, emails, removeEmails, clearSelectedThreads, addUndoAction, currentAccountId } = useAppStore.getState(); - if (!currentAccountId || selectedThreadIds.size === 0) return; + const effectiveAccountId = currentAccountId ?? emails.find(e => selectedThreadIds.has(e.threadId))?.accountId ?? null; + if (!effectiveAccountId || selectedThreadIds.size === 0) return; const threadIds = Array.from(selectedThreadIds); const allEmailIds: string[] = []; @@ -32,18 +33,18 @@ export function batchArchive() { id: `archive-batch-${Date.now()}`, type: "archive", threadCount: threadIds.length, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [...allEmails], 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 effectiveAccountId = currentAccountId ?? emails.find(e => selectedThreadIds.has(e.threadId))?.accountId ?? null; + if (!effectiveAccountId || selectedThreadIds.size === 0) return; const threadIds = Array.from(selectedThreadIds); const allEmailIds: string[] = []; @@ -65,18 +66,18 @@ export function batchTrash() { id: `trash-batch-${Date.now()}`, type: "trash", threadCount: threadIds.length, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [...allEmails], 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 effectiveAccountId = currentAccountId ?? emails.find(e => selectedThreadIds.has(e.threadId))?.accountId ?? null; + if (!effectiveAccountId || selectedThreadIds.size === 0) return; // Group emails by thread for the selected threads const selectedThreadEmails: Array<{ threadId: string; emails: typeof emails }> = []; @@ -123,7 +124,7 @@ export function batchToggleStar() { id: `${actionType}-batch-${Date.now()}`, type: actionType, threadCount: selectedThreadIds.size, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: changedEmails, scheduledAt: Date.now(), delayMs: 5000, @@ -136,7 +137,8 @@ export function batchToggleStar() { export function batchMarkUnread() { const { selectedThreadIds, emails, clearSelectedThreads, updateEmail, addUndoAction, currentAccountId } = useAppStore.getState(); - if (!currentAccountId || selectedThreadIds.size === 0) return; + const effectiveAccountId = currentAccountId ?? emails.find(e => selectedThreadIds.has(e.threadId))?.accountId ?? null; + if (!effectiveAccountId || selectedThreadIds.size === 0) return; const changedEmails: DashboardEmail[] = []; const previousLabels: Record = {}; @@ -163,7 +165,7 @@ export function batchMarkUnread() { id: `mark-unread-batch-${Date.now()}`, type: "mark-unread", threadCount: changedEmails.length, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: changedEmails, scheduledAt: Date.now(), delayMs: 5000, diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 20a84d4c..7571c56f 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -102,6 +102,10 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) const isGmail = keyboardBindings === "gmail"; + const effectiveAccountId = currentAccountId + ?? emails.find(em => em.id === selectedEmailId)?.accountId + ?? null; + // Store actions are stable references — safe to read from getState() const { setSelectedEmailId, @@ -387,7 +391,7 @@ 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 currentUserEmail = accounts.find((a: { id: string }) => a.id === effectiveAccountId)?.email; const getSearchThreads = () => mergeAndThreadSearchResults(activeSearchResults, remoteSearchResults, currentUserEmail); @@ -422,7 +426,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) // --- Helper: archive selected thread (all messages) --- const archiveSelected = () => { - if (!selectedEmailId || !selectedThreadId || !currentAccountId) return; + if (!selectedEmailId || !selectedThreadId || !effectiveAccountId) return; // Collect ALL emails in the thread for optimistic removal const threadEmails = getThreadEmails(selectedThreadId); @@ -470,20 +474,18 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `archive-${selectedThreadId}-${Date.now()}`, type: "archive", threadCount: 1, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [...threadEmails], scheduledAt: Date.now(), delayMs: 5000, - // If archive-ready view, include thread ID so it gets cleaned up on execute archiveReadyThreadIds: isArchiveReady ? [selectedThreadId] : undefined, }); - // Tracks intent — user may still undo within 5 s trackEvent("email_archived", { thread_count: 1, source: "keyboard" }); }; // --- Helper: trash selected thread --- const trashSelected = () => { - if (!selectedEmailId || !selectedThreadId || !currentAccountId) return; + if (!selectedEmailId || !selectedThreadId || !effectiveAccountId) return; const threadEmails = getThreadEmails(selectedThreadId); const threadEmailIds = threadEmails.map((item) => item.id); @@ -528,18 +530,17 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `trash-${selectedThreadId}-${Date.now()}`, type: "trash", threadCount: 1, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [...threadEmails], scheduledAt: Date.now(), delayMs: 5000, }); - // Tracks intent — user may still undo within 5 s trackEvent("email_trashed", { thread_count: 1, source: "keyboard" }); }; // --- Helper: mark selected thread as unread --- const markSelectedUnread = () => { - if (!selectedThreadId || !currentAccountId) return; + if (!selectedThreadId || !effectiveAccountId) return; const threadEmails = emails.filter((item) => item.threadId === selectedThreadId); if (threadEmails.length === 0) return; @@ -558,7 +559,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `mark-unread-${selectedThreadId}-${Date.now()}`, type: "mark-unread", threadCount: 1, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [latestEmail], scheduledAt: Date.now(), delayMs: 5000, @@ -869,7 +870,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 && effectiveAccountId) { e.preventDefault(); markThreadAsRead(selectedThreadId); if (viewMode === "full") { @@ -884,7 +885,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) if (isMultiSelect) { e.preventDefault(); batchToggleStar(); - } else if (isGmail && selectedThreadId && currentAccountId) { + } else if (isGmail && selectedThreadId && effectiveAccountId) { e.preventDefault(); const threadEmails = emails.filter((item) => item.threadId === selectedThreadId); if (threadEmails.length === 0) break; @@ -906,7 +907,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `unstar-${selectedThreadId}-${Date.now()}`, type: "unstar", threadCount: 1, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: starredEmails, scheduledAt: Date.now(), delayMs: 5000, @@ -920,7 +921,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) id: `star-${selectedThreadId}-${Date.now()}`, type: "star", threadCount: 1, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [latestEmail], scheduledAt: Date.now(), delayMs: 5000, @@ -979,9 +980,13 @@ 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 { + for (const a of accounts) window.api.sync.now(a.id).catch(console.error); + } } break; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 988660e9..339ac3ec 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -57,6 +57,31 @@ export type Account = { isConnected: boolean; }; +// Deterministic account color palette — assigned by index in the accounts array +export type AccountColor = { + bg: string; + text: string; + dot: string; +}; + +const ACCOUNT_COLORS: AccountColor[] = [ + { bg: "bg-blue-100 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-300", dot: "bg-blue-500" }, + { bg: "bg-purple-100 dark:bg-purple-900/30", text: "text-purple-700 dark:text-purple-300", dot: "bg-purple-500" }, + { bg: "bg-green-100 dark:bg-green-900/30", text: "text-green-700 dark:text-green-300", dot: "bg-green-500" }, + { bg: "bg-orange-100 dark:bg-orange-900/30", text: "text-orange-700 dark:text-orange-300", dot: "bg-orange-500" }, + { bg: "bg-pink-100 dark:bg-pink-900/30", text: "text-pink-700 dark:text-pink-300", dot: "bg-pink-500" }, + { bg: "bg-teal-100 dark:bg-teal-900/30", text: "text-teal-700 dark:text-teal-300", dot: "bg-teal-500" }, +]; + +export function getAccountColor(accounts: Account[], accountId: string): AccountColor { + const idx = accounts.findIndex((a) => a.id === accountId); + return ACCOUNT_COLORS[(idx === -1 ? 0 : idx) % ACCOUNT_COLORS.length]; +} + +export function getAccountLabel(account: Account): string { + return account.email; +} + // Sync status per account export type SyncStatus = "idle" | "syncing" | "error"; @@ -736,14 +761,14 @@ export const useAppStore = create((set, get) => ({ })), // 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, + set((state) => { + if (state.currentAccountId !== null) return { accounts }; + // Default to "All accounts" (null) when multiple accounts exist, + // otherwise select the single account directly. + return { + accounts, + currentAccountId: accounts.length > 1 ? null : accounts[0]?.id ?? null, + }; }), addAccount: (account) => set((state) => { From b0a9a1c7df685fac3fdefb107210032685d0557c Mon Sep 17 00:00:00 2001 From: Hans Kristoffer Date: Mon, 30 Mar 2026 23:15:57 +0200 Subject: [PATCH 2/3] Add All-accounts mode and per-account batching Support a unified "All accounts" inbox and ensure actions work correctly across multiple accounts. Use a Set of user emails for sent-detection and pass it through threading/search utilities. Derive effective account IDs when needed (falling back to primary/first account for compose) and only store remote search nextPageToken for single-account searches. Batch actions (archive/trash/star/unread) now group emails by account and enqueue one undo action per account so backend calls receive the correct accountId. Load snoozed/archive data in parallel and merge results before setting state. Add a Settings option to choose defaultAccountView (all or primary) and persist it in config schema. Also update tailwind plugin import to use @tailwindcss/forms via ESM import. --- src/renderer/App.tsx | 55 ++++++-- src/renderer/components/EmailDetail.tsx | 10 +- src/renderer/components/EmailList.tsx | 33 +++-- src/renderer/components/SearchBar.tsx | 4 +- src/renderer/components/SettingsPanel.tsx | 59 ++++++++ src/renderer/hooks/useBatchActions.ts | 155 +++++++++++++-------- src/renderer/hooks/useKeyboardShortcuts.ts | 6 +- src/renderer/store/index.ts | 51 ++++--- src/renderer/utils/searchResults.ts | 4 +- src/shared/types.ts | 1 + tailwind.config.js | 4 +- 11 files changed, 267 insertions(+), 115 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 381cfc5e..86f3d6ce 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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,9 +611,17 @@ export default function App() { })); setAccounts(fullAccounts); - // Default to "All accounts" when multiple accounts, single account otherwise + // 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) { - setCurrentAccountId(null); + 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); } @@ -1165,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"); @@ -1763,7 +1787,10 @@ function SnoozeOverlay() { const selectedEmail = emails.find((e) => e.id === selectedEmailId); - if (!showSnoozeMenu || !selectedEmail || !currentAccountId) return null; + // In "All accounts" mode currentAccountId is null — derive from the selected email + const effectiveAccountId = currentAccountId ?? selectedEmail?.accountId ?? null; + + if (!showSnoozeMenu || !selectedEmail || !effectiveAccountId) return null; // Determine if we're in batch mode (any multi-select, even 1 thread via 'x') const isBatchSnooze = selectedThreadIds.size > 0; @@ -1783,7 +1810,7 @@ function SnoozeOverlay() { { if (isBatchSnooze) { // Batch snooze: snooze all selected threads using the same snoozeUntil time @@ -1821,7 +1848,7 @@ function SnoozeOverlay() { id: `snooze-${tid}-${Date.now()}`, emailId: thread.latestEmail.id, threadId: tid, - accountId: currentAccountId, + accountId: effectiveAccountId, snoozeUntil: snoozedEmail.snoozeUntil, snoozedAt: snoozedEmail.snoozedAt, }); @@ -1836,7 +1863,7 @@ function SnoozeOverlay() { id: `snooze-batch-${Date.now()}`, type: "snooze", threadCount: threadIdsToSnooze.length, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [], scheduledAt: Date.now(), delayMs: 5000, @@ -1850,7 +1877,7 @@ function SnoozeOverlay() { (window as any).api.snooze.snooze( thread.latestEmail.id, tid, - currentAccountId, + effectiveAccountId, snoozeUntil, ).catch((err: unknown) => console.error("Batch snooze failed for thread", tid, err)); } @@ -1893,7 +1920,7 @@ function SnoozeOverlay() { id: `snooze-${snoozedThreadId}-${Date.now()}`, type: "snooze", threadCount: 1, - accountId: currentAccountId, + accountId: effectiveAccountId, emails: [], scheduledAt: Date.now(), delayMs: 5000, diff --git a/src/renderer/components/EmailDetail.tsx b/src/renderer/components/EmailDetail.tsx index bda95802..697c5746 100644 --- a/src/renderer/components/EmailDetail.tsx +++ b/src/renderer/components/EmailDetail.tsx @@ -1940,8 +1940,14 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { // In "All accounts" mode currentAccountId is null, so derive the effective // account from the selected email so thread fetch, attachments, compose, and - // actions all work correctly. - const effectiveAccountId = currentAccountId ?? selectedEmail?.accountId ?? null; + // actions all work correctly. Fall back to the primary/first account so that + // composing a new email works even when no email is selected. + const effectiveAccountId = + currentAccountId + ?? selectedEmail?.accountId + ?? accounts.find(a => a.isPrimary)?.id + ?? accounts[0]?.id + ?? null; const effectiveAccount = accounts.find(a => a.id === effectiveAccountId); const effectiveUserEmail = effectiveAccount?.email; diff --git a/src/renderer/components/EmailList.tsx b/src/renderer/components/EmailList.tsx index dcfac44b..3256b634 100644 --- a/src/renderer/components/EmailList.tsx +++ b/src/renderer/components/EmailList.tsx @@ -114,24 +114,27 @@ export function EmailList() { }, [openCompose, setSelectedEmailId, setSelectedThreadId, setSelectedDraftId, setViewMode]); // Load snoozed emails on mount / account switch. - // In "All" mode, iterate over every account and merge results. + // In "All" mode, fetch every account in parallel then merge before calling the + // setter once — setSnoozedThreads is a replace operation so calling it per-account + // in a loop would discard all but the last response. useEffect(() => { const accountIds = currentAccountId ? [currentAccountId] : accounts.map((a) => a.id); if (accountIds.length === 0) return; - for (const accountId of accountIds) { + const promises = accountIds.map((accountId) => (window as any).api.snooze.list(accountId).then((response: any) => { - if (response.success && response.data) { - setSnoozedThreads(response.data); - } if (response.expired?.length > 0) { const store = useAppStore.getState(); for (const email of response.expired) { store.handleThreadUnsnoozed(email.threadId, email.snoozeUntil); } } - }); - } + return response.success && response.data ? response.data : []; + }) + ); + Promise.all(promises).then((results) => { + setSnoozedThreads(results.flat()); + }); }, [currentAccountId, accounts, setSnoozedThreads]); // Listen for snooze events from main process, filtered by current account. @@ -168,22 +171,26 @@ export function EmailList() { }, []); // Load archive-ready threads on mount / account switch. - // In "All" mode, iterate over every account and merge results. + // Same Promise.all pattern as snooze loading — setArchiveReadyThreads replaces + // state, so we must collect all accounts before calling it once. useEffect(() => { const accountIds = currentAccountId ? [currentAccountId] : accounts.map((a) => a.id); if (accountIds.length === 0) return; - for (const accountId of accountIds) { + const promises = accountIds.map((accountId) => (window as any).api.archiveReady.getThreads(accountId).then((result: any) => { if (result.success && result.data) { - const items = result.data.map((t: { threadId: string; reason: string }) => ({ + return result.data.map((t: { threadId: string; reason: string }) => ({ threadId: t.threadId, reason: t.reason, })); - setArchiveReadyThreads(items); } - }); - } + return []; + }) + ); + Promise.all(promises).then((results) => { + setArchiveReadyThreads(results.flat()); + }); }, [currentAccountId, accounts, setArchiveReadyThreads]); // Listen for new archive-ready results from background prefetch diff --git a/src/renderer/components/SearchBar.tsx b/src/renderer/components/SearchBar.tsx index 8d62922b..ec4f41d2 100644 --- a/src/renderer/components/SearchBar.tsx +++ b/src/renderer/components/SearchBar.tsx @@ -159,7 +159,9 @@ export function SearchBar({ isOpen, onClose }: SearchBarProps) { if (newEmails.length > 0) { setRemoteSearchResults([...existing, ...newEmails]); } - if (response.data.nextPageToken) { + // 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 { 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 */}