diff --git a/app/(main)/[locale]/contacts/page.tsx b/app/(main)/[locale]/contacts/page.tsx index 28818bd9..c8fb7a46 100644 --- a/app/(main)/[locale]/contacts/page.tsx +++ b/app/(main)/[locale]/contacts/page.tsx @@ -27,6 +27,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; @@ -140,12 +141,18 @@ export default function ContactsPage() { } }, [initialCheckDone, isAuthenticated, authLoading]); + // Pro shell only: aggregate contacts and address books from every + // connected account so the sidebar lists them all. The hook is a no-op + // outside the embedded shell. + const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts(); + useEffect(() => { + if (isEmbedded) return; if (client && supportsSync && !hasFetched.current) { hasFetched.current = true; fetchContacts(client); } - }, [client, supportsSync, fetchContacts]); + }, [client, supportsSync, fetchContacts, isEmbedded]); // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh contacts via JMAP instead of reloading the page. @@ -153,6 +160,17 @@ export default function ContactsPage() { enabled: isAuthenticated && !!client && supportsSync, onRefresh: async () => { if (!client) return; + if (multiAccountEnabled && accountClients.length > 0) { + const activeId = useAuthStore.getState().activeAccountId; + if (activeId) { + const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState(); + await Promise.all([ + fetchAllAccountsAddressBooks(accountClients, activeId), + fetchAllAccountsContacts(accountClients, activeId), + ]); + return; + } + } await fetchContacts(client); }, }); @@ -759,6 +777,7 @@ export default function ContactsPage() { } } : undefined} onRenameKeyword={(kw) => setRenamingKeyword(kw)} + multiAccountMode={multiAccountEnabled && accountClients.length > 1} /> {!isNarrow && ( diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 07afccf1..2403b33d 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useTranslations } from "next-intl"; -import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react"; +import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; @@ -10,6 +10,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; +import { useAccountStore } from "@/stores/account-store"; export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; @@ -32,6 +33,36 @@ interface ContactsSidebarProps { onDeleteAddressBook?: (addressBook: AddressBook) => void; onRenameKeyword?: (keyword: string) => void; className?: string; + /** + * Pro shell: render one collapsible section per connected local account + * (active first), each with "My Address Books" / "Shared from X" + * subsections. Mirrors the calendar sidebar's Pro layout. + */ + multiAccountMode?: boolean; +} + +type AddressBookAccountSplit = { + owned: AddressBook[]; + sharedGroups: { label: string; books: AddressBook[] }[]; +}; + +function splitAccountBooks(list: AddressBook[]): AddressBookAccountSplit { + const owned: AddressBook[] = []; + const sharedBuckets = new Map(); + for (const book of list) { + if (book.isShared) { + const key = book.accountId || book.accountName || book.id; + const bucket = sharedBuckets.get(key); + if (bucket) { + bucket.books.push(book); + } else { + sharedBuckets.set(key, { label: book.accountName || key, books: [book] }); + } + } else { + owned.push(book); + } + } + return { owned, sharedGroups: Array.from(sharedBuckets.values()) }; } const COLLAPSED_KEY = "contacts-sidebar-collapsed"; @@ -70,6 +101,7 @@ export function ContactsSidebar({ onDeleteAddressBook, onRenameKeyword, className, + multiAccountMode, }: ContactsSidebarProps) { const t = useTranslations("contacts"); const router = useRouter(); @@ -136,6 +168,47 @@ export function ContactsSidebar({ return Array.from(map.values()); }, [addressBooks]); + // Pro / multi-account grouping: each local account is its own collapsible + // section with owned / shared sub-buckets. + const localAccounts = useAccountStore((s) => s.accounts); + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const localAccountGroups = useMemo(() => { + if (!multiAccountMode) return []; + const byAccount = new Map(); + for (const book of addressBooks) { + const key = book.localAccountId || '__other__'; + const list = byAccount.get(key) ?? []; + list.push(book); + byAccount.set(key, list); + } + const ordered: { key: string; label: string; split: AddressBookAccountSplit }[] = []; + if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) { + const acct = localAccounts.find(a => a.id === activeLocalAccountId); + ordered.push({ + key: activeLocalAccountId, + label: acct?.label || acct?.email || acct?.username || activeLocalAccountId, + split: splitAccountBooks(byAccount.get(activeLocalAccountId)!), + }); + byAccount.delete(activeLocalAccountId); + } + for (const acct of localAccounts) { + if (!byAccount.has(acct.id)) continue; + ordered.push({ + key: acct.id, + label: acct.label || acct.email || acct.username, + split: splitAccountBooks(byAccount.get(acct.id)!), + }); + byAccount.delete(acct.id); + } + for (const [key, list] of byAccount.entries()) { + const fallback = key === '__other__' + ? t('address_books.title') + : list[0]?.accountName || key; + ordered.push({ key, label: fallback, split: splitAccountBooks(list) }); + } + return ordered; + }, [multiAccountMode, addressBooks, localAccounts, activeLocalAccountId, t]); + // Count contacts per address book const contactCountByBook = useMemo(() => { const counts: Record = {}; @@ -257,47 +330,117 @@ export function ContactsSidebar({ - {/* My Address Books */} - {personalBooks.length > 0 && ( -
-
- +
+ {expanded && ( +
+ {owned.length > 0 && ( +
+
+ {t("address_books.title")} +
+ {owned.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + /> + ))} +
+ )} + {sharedGroups.map((sg) => ( +
+
+ + {sg.label} +
+ {sg.books.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + /> + ))} +
+ ))} +
)} - - {t("address_books.title")} - - - +
+ ); + }) + ) : ( + personalBooks.length > 0 && ( +
+
+ + +
+ {!collapsed.addressBooks && personalBooks.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + /> + ))}
- {!collapsed.addressBooks && personalBooks.map((book) => ( - onSelectCategory({ addressBookId: book.id })} - onDropContacts={onDropContacts} - onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} - /> - ))} - + ) )} {/* Groups section */} @@ -398,8 +541,9 @@ export function ContactsSidebar({ )} - {/* Shared accounts with address books */} - {sharedBookGroups.map((group) => ( + {/* Shared accounts with address books — only when not already split + into per-account groups above (multi-account Pro mode). */} + {!multiAccountMode && sharedBookGroups.map((group) => (