feat: show contacts from all logged-in accounts in Pro shell
This commit is contained in:
@@ -27,6 +27,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
@@ -140,12 +141,18 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && supportsSync && !hasFetched.current) {
|
if (client && supportsSync && !hasFetched.current) {
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
fetchContacts(client);
|
fetchContacts(client);
|
||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts, isEmbedded]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh contacts via JMAP instead of reloading the page.
|
// and refresh contacts via JMAP instead of reloading the page.
|
||||||
@@ -153,6 +160,17 @@ export default function ContactsPage() {
|
|||||||
enabled: isAuthenticated && !!client && supportsSync,
|
enabled: isAuthenticated && !!client && supportsSync,
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (!client) return;
|
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);
|
await fetchContacts(client);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -759,6 +777,7 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
|
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isNarrow && (
|
{!isNarrow && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
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 { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
|
||||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||||
|
|
||||||
@@ -32,6 +33,36 @@ interface ContactsSidebarProps {
|
|||||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||||
onRenameKeyword?: (keyword: string) => void;
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
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<string, { label: string; books: AddressBook[] }>();
|
||||||
|
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";
|
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
||||||
@@ -70,6 +101,7 @@ export function ContactsSidebar({
|
|||||||
onDeleteAddressBook,
|
onDeleteAddressBook,
|
||||||
onRenameKeyword,
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
|
multiAccountMode,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -136,6 +168,47 @@ export function ContactsSidebar({
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}, [addressBooks]);
|
}, [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<string, AddressBook[]>();
|
||||||
|
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
|
// Count contacts per address book
|
||||||
const contactCountByBook = useMemo(() => {
|
const contactCountByBook = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
@@ -257,47 +330,117 @@ export function ContactsSidebar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* My Address Books */}
|
{/* Address Books: per-account groups in multi-account Pro mode, else the
|
||||||
{personalBooks.length > 0 && (
|
classic "My Address Books" section. */}
|
||||||
<div className="mt-2">
|
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||||
<div className="flex items-center px-3 py-1 group">
|
localAccountGroups.map((group) => {
|
||||||
<button
|
const sectionKey = `account-${group.key}`;
|
||||||
onClick={() => toggleSection("addressBooks")}
|
const expanded = !collapsed[sectionKey];
|
||||||
className="flex items-center gap-1 flex-1 text-left"
|
const { owned, sharedGroups } = group.split;
|
||||||
>
|
return (
|
||||||
{collapsed.addressBooks ? (
|
<div key={group.key} className="mt-2">
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<div className="flex items-center px-3 py-1 group">
|
||||||
) : (
|
<button
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
onClick={() => toggleSection(sectionKey)}
|
||||||
|
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<User className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-semibold text-foreground/90 uppercase tracking-wider truncate">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="pl-2">
|
||||||
|
{owned.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
{t("address_books.title")}
|
||||||
|
</div>
|
||||||
|
{owned.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sharedGroups.map((sg) => (
|
||||||
|
<div key={`${group.key}-shared-${sg.label}`} className="mt-1">
|
||||||
|
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
{sg.label}
|
||||||
|
</div>
|
||||||
|
{sg.books.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
</div>
|
||||||
{t("address_books.title")}
|
);
|
||||||
</span>
|
})
|
||||||
</button>
|
) : (
|
||||||
<button
|
personalBooks.length > 0 && (
|
||||||
onClick={(e) => {
|
<div className="mt-2">
|
||||||
e.stopPropagation();
|
<div className="flex items-center px-3 py-1 group">
|
||||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
<button
|
||||||
router.push('/settings');
|
onClick={() => toggleSection("addressBooks")}
|
||||||
}}
|
className="flex items-center gap-1 flex-1 text-left"
|
||||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
>
|
||||||
title={t("address_books.manage")}
|
{collapsed.addressBooks ? (
|
||||||
>
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
) : (
|
||||||
</button>
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t("address_books.title")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||||
|
title={t("address_books.manage")}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
)
|
||||||
<AddressBookItem
|
|
||||||
key={book.id}
|
|
||||||
book={book}
|
|
||||||
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
|
||||||
contactCount={contactCountByBook[book.id] || 0}
|
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
|
||||||
onDropContacts={onDropContacts}
|
|
||||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Groups section */}
|
{/* Groups section */}
|
||||||
@@ -398,8 +541,9 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shared accounts with address books */}
|
{/* Shared accounts with address books — only when not already split
|
||||||
{sharedBookGroups.map((group) => (
|
into per-account groups above (multi-account Pro mode). */}
|
||||||
|
{!multiAccountMode && sharedBookGroups.map((group) => (
|
||||||
<div key={group.accountId} className="mt-2">
|
<div key={group.accountId} className="mt-2">
|
||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useContactStore, type ContactAccountClient } from "@/stores/contact-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pro-shell counterpart to [[useProMultiAccountCalendars]] — aggregates
|
||||||
|
* contacts and address books from every connected JMAP account so the
|
||||||
|
* contacts sidebar lists them all, grouped by local account.
|
||||||
|
*/
|
||||||
|
export function useProMultiAccountContacts(): {
|
||||||
|
enabled: boolean;
|
||||||
|
accountClients: ContactAccountClient[];
|
||||||
|
} {
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
|
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
const fetchAllAccountsAddressBooks = useContactStore((s) => s.fetchAllAccountsAddressBooks);
|
||||||
|
const fetchAllAccountsContacts = useContactStore((s) => s.fetchAllAccountsContacts);
|
||||||
|
|
||||||
|
const enabled = proInterface || isEmbedded;
|
||||||
|
|
||||||
|
const accountClients = useMemo(() => {
|
||||||
|
if (!enabled) return [];
|
||||||
|
const getClientForAccount = useAuthStore.getState().getClientForAccount;
|
||||||
|
const pairs: ContactAccountClient[] = [];
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (!account.isConnected) continue;
|
||||||
|
const client = getClientForAccount(account.id);
|
||||||
|
if (!client || !client.supportsContacts()) continue;
|
||||||
|
pairs.push({ localAccountId: account.id, client });
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}, [enabled, accounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !activeAccountId || accountClients.length === 0) return;
|
||||||
|
void fetchAllAccountsAddressBooks(accountClients, activeAccountId);
|
||||||
|
void fetchAllAccountsContacts(accountClients, activeAccountId);
|
||||||
|
}, [enabled, activeAccountId, accountClients, fetchAllAccountsAddressBooks, fetchAllAccountsContacts]);
|
||||||
|
|
||||||
|
return { enabled, accountClients };
|
||||||
|
}
|
||||||
@@ -172,6 +172,9 @@ export interface ContactCard {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
|
// Local account-store ID — set when the Pro shell aggregates contacts
|
||||||
|
// from multiple connected accounts. See `Calendar.localAccountId`.
|
||||||
|
localAccountId?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
name?: ContactName;
|
name?: ContactName;
|
||||||
nicknames?: Record<string, ContactNickname>;
|
nicknames?: Record<string, ContactNickname>;
|
||||||
@@ -372,6 +375,8 @@ export interface AddressBook {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
|
// See `Calendar.localAccountId` — same purpose for address books.
|
||||||
|
localAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressBookRights {
|
export interface AddressBookRights {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
|||||||
import { JMAPClient, RateLimitError } from '@/lib/jmap/client';
|
import { JMAPClient, RateLimitError } from '@/lib/jmap/client';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useIdentityStore } from './identity-store';
|
import { useIdentityStore } from './identity-store';
|
||||||
|
import { setClientLookup } from './client-registry';
|
||||||
import { useContactStore } from './contact-store';
|
import { useContactStore } from './contact-store';
|
||||||
import { useVacationStore } from './vacation-store';
|
import { useVacationStore } from './vacation-store';
|
||||||
import { useCalendarStore } from './calendar-store';
|
import { useCalendarStore } from './calendar-store';
|
||||||
@@ -1661,3 +1662,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Expose getClientForAccount to the calendar/contact stores via a small
|
||||||
|
// shared registry — see [[stores/client-registry]] for rationale.
|
||||||
|
setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
|||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||||
import { useAuthStore } from './auth-store';
|
import { getClientByLocalAccountId } from './client-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the Pro shell aggregates calendars/events from every connected
|
* When the Pro shell aggregates calendars/events from every connected
|
||||||
@@ -19,10 +19,14 @@ import { useAuthStore } from './auth-store';
|
|||||||
* client (passed in by the page) could be on a different server entirely.
|
* client (passed in by the page) could be on a different server entirely.
|
||||||
* Falls back to the active client when `localAccountId` is unset or no
|
* Falls back to the active client when `localAccountId` is unset or no
|
||||||
* matching client is registered.
|
* matching client is registered.
|
||||||
|
*
|
||||||
|
* Lookup goes through `client-registry` (not a direct auth-store import)
|
||||||
|
* to avoid a top-level cycle: auth-store already imports this module to
|
||||||
|
* bootstrap feature stores after login.
|
||||||
*/
|
*/
|
||||||
function resolveAccountClient<T extends IJMAPClient>(active: T, localAccountId?: string): T {
|
function resolveAccountClient<T extends IJMAPClient>(active: T, localAccountId?: string): T {
|
||||||
if (!localAccountId) return active;
|
if (!localAccountId) return active;
|
||||||
const lookup = useAuthStore.getState().getClientForAccount(localAccountId) as T | undefined;
|
const lookup = getClientByLocalAccountId(localAccountId) as T | undefined;
|
||||||
return lookup ?? active;
|
return lookup ?? active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny indirection used by the calendar and contact stores to look up a
|
||||||
|
* JMAP client by local account ID without importing `auth-store` directly
|
||||||
|
* — that would form a top-level cycle (auth-store already imports the
|
||||||
|
* feature stores to bootstrap them after login).
|
||||||
|
*
|
||||||
|
* `auth-store` registers its `getClientForAccount` on module init via
|
||||||
|
* `setClientLookup`; the feature stores call `getClientByLocalAccountId`
|
||||||
|
* inside their mutations.
|
||||||
|
*/
|
||||||
|
type ClientLookup = (localAccountId: string) => IJMAPClient | undefined;
|
||||||
|
|
||||||
|
let lookup: ClientLookup | null = null;
|
||||||
|
|
||||||
|
export function setClientLookup(fn: ClientLookup): void {
|
||||||
|
lookup = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClientByLocalAccountId(localAccountId: string): IJMAPClient | undefined {
|
||||||
|
return lookup ? lookup(localAccountId) : undefined;
|
||||||
|
}
|
||||||
+145
-6
@@ -4,6 +4,82 @@ import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@
|
|||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
|
import { getClientByLocalAccountId } from './client-registry';
|
||||||
|
|
||||||
|
/** One connected JMAP account for contact multi-account aggregation. */
|
||||||
|
export interface ContactAccountClient {
|
||||||
|
localAccountId: string;
|
||||||
|
client: IJMAPClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix used to namespace contact/address-book IDs that belong to a
|
||||||
|
* non-active JMAP account when the Pro shell aggregates across accounts.
|
||||||
|
* The active account's IDs are left untouched so existing single-account
|
||||||
|
* code paths keep working unchanged.
|
||||||
|
*/
|
||||||
|
const CROSS_ACCOUNT_ID_DELIMITER = '::';
|
||||||
|
|
||||||
|
function buildCrossAccountIdPrefix(localAccountId: string): string {
|
||||||
|
return `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixAddressBooksWithLocalAccount(
|
||||||
|
books: AddressBook[],
|
||||||
|
localAccountId: string,
|
||||||
|
isActiveAccount: boolean,
|
||||||
|
): AddressBook[] {
|
||||||
|
if (isActiveAccount) {
|
||||||
|
return books.map((b) => ({ ...b, localAccountId }));
|
||||||
|
}
|
||||||
|
const prefix = buildCrossAccountIdPrefix(localAccountId);
|
||||||
|
return books.map((b) => ({
|
||||||
|
...b,
|
||||||
|
id: `${prefix}${b.id}`,
|
||||||
|
localAccountId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixContactsWithLocalAccount(
|
||||||
|
contacts: ContactCard[],
|
||||||
|
localAccountId: string,
|
||||||
|
isActiveAccount: boolean,
|
||||||
|
): ContactCard[] {
|
||||||
|
if (isActiveAccount) {
|
||||||
|
return contacts.map((c) => ({ ...c, localAccountId }));
|
||||||
|
}
|
||||||
|
const prefix = buildCrossAccountIdPrefix(localAccountId);
|
||||||
|
return contacts.map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: `${prefix}${c.id}`,
|
||||||
|
localAccountId,
|
||||||
|
addressBookIds: c.addressBookIds
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(c.addressBookIds).map(([bookId, v]) => [`${prefix}${bookId}`, v]),
|
||||||
|
)
|
||||||
|
: c.addressBookIds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route mutations back through the client that owns the target entity
|
||||||
|
* when in multi-account Pro mode. See [[useProMultiAccountContacts]].
|
||||||
|
*
|
||||||
|
* Lookup goes through `client-registry` (not a direct auth-store import)
|
||||||
|
* to avoid a top-level cycle: auth-store already imports this module to
|
||||||
|
* bootstrap feature stores after login.
|
||||||
|
*/
|
||||||
|
function resolveAccountClient<T extends IJMAPClient>(active: T, localAccountId?: string): T {
|
||||||
|
if (!localAccountId) return active;
|
||||||
|
const lookup = getClientByLocalAccountId(localAccountId) as T | undefined;
|
||||||
|
return lookup ?? active;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripLocalAccountPrefix(id: string, localAccountId?: string): string {
|
||||||
|
if (!localAccountId) return id;
|
||||||
|
const prefix = `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
|
||||||
|
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
||||||
|
}
|
||||||
|
|
||||||
export function getContactDisplayName(contact: ContactCard): string {
|
export function getContactDisplayName(contact: ContactCard): string {
|
||||||
if (contact.name) {
|
if (contact.name) {
|
||||||
@@ -85,6 +161,8 @@ interface ContactStore {
|
|||||||
|
|
||||||
fetchContacts: (client: IJMAPClient) => Promise<void>;
|
fetchContacts: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
|
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
|
||||||
|
fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
|
||||||
|
fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
|
||||||
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
||||||
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
|
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
|
||||||
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
|
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
|
||||||
@@ -202,12 +280,64 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchAllAccountsContacts: async (accounts, activeLocalAccountId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
accounts.map(async ({ client, localAccountId }) => {
|
||||||
|
try {
|
||||||
|
const list = await client.getAllContacts();
|
||||||
|
return prefixContactsWithLocalAccount(
|
||||||
|
list,
|
||||||
|
localAccountId,
|
||||||
|
localAccountId === activeLocalAccountId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to fetch contacts for account ${localAccountId}:`, error);
|
||||||
|
return [] as ContactCard[];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
set({ contacts: results.flat(), isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all-account contacts:', error);
|
||||||
|
set({ error: 'Failed to fetch contacts', isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchAllAccountsAddressBooks: async (accounts, activeLocalAccountId) => {
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
accounts.map(async ({ client, localAccountId }) => {
|
||||||
|
try {
|
||||||
|
const list = await client.getAllAddressBooks();
|
||||||
|
return prefixAddressBooksWithLocalAccount(
|
||||||
|
list,
|
||||||
|
localAccountId,
|
||||||
|
localAccountId === activeLocalAccountId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to fetch address books for account ${localAccountId}:`, error);
|
||||||
|
return [] as AddressBook[];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
set({ addressBooks: results.flat() });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all-account address books:', error);
|
||||||
|
set({ error: 'Failed to fetch address books' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createContact: async (client, contact) => {
|
createContact: async (client, contact) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
// Determine target account from the selected address book
|
// Determine target account from the selected address book. Also
|
||||||
|
// pin the local account so we route through the right server's
|
||||||
|
// client in multi-account Pro mode.
|
||||||
let accountId = contact.isShared ? contact.accountId : undefined;
|
let accountId = contact.isShared ? contact.accountId : undefined;
|
||||||
let cleanedContact = contact;
|
let cleanedContact = contact;
|
||||||
|
let localAccountId = contact.localAccountId;
|
||||||
|
|
||||||
// De-namespace addressBookIds if they reference a shared address book
|
// De-namespace addressBookIds if they reference a shared address book
|
||||||
if (contact.addressBookIds) {
|
if (contact.addressBookIds) {
|
||||||
@@ -216,9 +346,12 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
let sharedAccountId: string | undefined;
|
let sharedAccountId: string | undefined;
|
||||||
for (const [bookId, value] of Object.entries(contact.addressBookIds)) {
|
for (const [bookId, value] of Object.entries(contact.addressBookIds)) {
|
||||||
const book = books.find(b => b.id === bookId);
|
const book = books.find(b => b.id === bookId);
|
||||||
|
if (book?.localAccountId) localAccountId = book.localAccountId;
|
||||||
if (book?.isShared && book.originalId) {
|
if (book?.isShared && book.originalId) {
|
||||||
deNamespaced[book.originalId] = value;
|
deNamespaced[book.originalId] = value;
|
||||||
sharedAccountId = book.accountId;
|
sharedAccountId = book.accountId;
|
||||||
|
} else if (book?.originalId) {
|
||||||
|
deNamespaced[book.originalId] = value;
|
||||||
} else {
|
} else {
|
||||||
deNamespaced[bookId] = value;
|
deNamespaced[bookId] = value;
|
||||||
}
|
}
|
||||||
@@ -231,6 +364,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client = resolveAccountClient(client, localAccountId);
|
||||||
const created = await client.createContact(cleanedContact, accountId);
|
const created = await client.createContact(cleanedContact, accountId);
|
||||||
// Preserve shared account metadata
|
// Preserve shared account metadata
|
||||||
if (contact.isShared && contact.accountId) {
|
if (contact.isShared && contact.accountId) {
|
||||||
@@ -255,8 +389,9 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const contact = get().contacts.find(c => c.id === id);
|
const contact = get().contacts.find(c => c.id === id);
|
||||||
const originalId = contact?.originalId || id;
|
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
|
client = resolveAccountClient(client, contact?.localAccountId);
|
||||||
|
|
||||||
// De-namespace addressBookIds for shared contacts before sending to JMAP server
|
// De-namespace addressBookIds for shared contacts before sending to JMAP server
|
||||||
let cleanedUpdates = updates;
|
let cleanedUpdates = updates;
|
||||||
@@ -288,8 +423,9 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const contact = get().contacts.find(c => c.id === id);
|
const contact = get().contacts.find(c => c.id === id);
|
||||||
const originalId = contact?.originalId || id;
|
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
|
client = resolveAccountClient(client, contact?.localAccountId);
|
||||||
await client.deleteContact(originalId, accountId);
|
await client.deleteContact(originalId, accountId);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const removedIds = new Set([id]);
|
const removedIds = new Set([id]);
|
||||||
@@ -659,8 +795,9 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
const trimmed = newName.trim();
|
const trimmed = newName.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
try {
|
try {
|
||||||
const originalId = addressBook.originalId || addressBook.id;
|
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
|
||||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||||
|
client = resolveAccountClient(client, addressBook.localAccountId);
|
||||||
await client.updateAddressBook(originalId, { name: trimmed }, accountId);
|
await client.updateAddressBook(originalId, { name: trimmed }, accountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
addressBooks: state.addressBooks.map(b =>
|
addressBooks: state.addressBooks.map(b =>
|
||||||
@@ -677,8 +814,9 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
removeAddressBook: async (client, addressBook) => {
|
removeAddressBook: async (client, addressBook) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const originalId = addressBook.originalId || addressBook.id;
|
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
|
||||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||||
|
client = resolveAccountClient(client, addressBook.localAccountId);
|
||||||
await client.deleteAddressBook(originalId, accountId);
|
await client.deleteAddressBook(originalId, accountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
|
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
|
||||||
@@ -694,8 +832,9 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
shareAddressBook: async (client, addressBook, principalId, rights) => {
|
shareAddressBook: async (client, addressBook, principalId, rights) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const originalId = addressBook.originalId || addressBook.id;
|
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
|
||||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||||
|
client = resolveAccountClient(client, addressBook.localAccountId);
|
||||||
await client.setAddressBookShare(originalId, principalId, rights, accountId);
|
await client.setAddressBookShare(originalId, principalId, rights, accountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
addressBooks: state.addressBooks.map(b => {
|
addressBooks: state.addressBooks.map(b => {
|
||||||
|
|||||||
Reference in New Issue
Block a user