feat: show contacts from all logged-in accounts in Pro shell

This commit is contained in:
Linus Rath
2026-05-21 22:32:17 +02:00
parent 2c825af689
commit e80412b6fd
8 changed files with 438 additions and 51 deletions
+20 -1
View File
@@ -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 && (
+186 -42
View File
@@ -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
+48
View File
@@ -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 };
}
+5
View File
@@ -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 {
+5
View File
@@ -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));
+6 -2
View File
@@ -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;
} }
+23
View File
@@ -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
View File
@@ -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 => {