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 { 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}
|
||||
/>
|
||||
</div>
|
||||
{!isNarrow && (
|
||||
|
||||
@@ -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<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";
|
||||
@@ -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<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
|
||||
const contactCountByBook = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
@@ -257,8 +330,77 @@ export function ContactsSidebar({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* My Address Books */}
|
||||
{personalBooks.length > 0 && (
|
||||
{/* Address Books: per-account groups in multi-account Pro mode, else the
|
||||
classic "My Address Books" section. */}
|
||||
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||
localAccountGroups.map((group) => {
|
||||
const sectionKey = `account-${group.key}`;
|
||||
const expanded = !collapsed[sectionKey];
|
||||
const { owned, sharedGroups } = group.split;
|
||||
return (
|
||||
<div key={group.key} className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
@@ -298,6 +440,7 @@ export function ContactsSidebar({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Groups section */}
|
||||
@@ -398,8 +541,9 @@ export function ContactsSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<div key={group.accountId} className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<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;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
// Local account-store ID — set when the Pro shell aggregates contacts
|
||||
// from multiple connected accounts. See `Calendar.localAccountId`.
|
||||
localAccountId?: string;
|
||||
language?: string;
|
||||
name?: ContactName;
|
||||
nicknames?: Record<string, ContactNickname>;
|
||||
@@ -372,6 +375,8 @@ export interface AddressBook {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
// See `Calendar.localAccountId` — same purpose for address books.
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
export interface AddressBookRights {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import { JMAPClient, RateLimitError } from '@/lib/jmap/client';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { useIdentityStore } from './identity-store';
|
||||
import { setClientLookup } from './client-registry';
|
||||
import { useContactStore } from './contact-store';
|
||||
import { useVacationStore } from './vacation-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 { apiFetch } from '@/lib/browser-navigation';
|
||||
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
|
||||
@@ -19,10 +19,14 @@ import { useAuthStore } from './auth-store';
|
||||
* 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
|
||||
* 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 {
|
||||
if (!localAccountId) return active;
|
||||
const lookup = useAuthStore.getState().getClientForAccount(localAccountId) as T | undefined;
|
||||
const lookup = getClientByLocalAccountId(localAccountId) as T | undefined;
|
||||
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 { generateUUID } from '@/lib/utils';
|
||||
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 {
|
||||
if (contact.name) {
|
||||
@@ -85,6 +161,8 @@ interface ContactStore {
|
||||
|
||||
fetchContacts: (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>;
|
||||
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => 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) => {
|
||||
set({ isLoading: true, error: null });
|
||||
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 cleanedContact = contact;
|
||||
let localAccountId = contact.localAccountId;
|
||||
|
||||
// De-namespace addressBookIds if they reference a shared address book
|
||||
if (contact.addressBookIds) {
|
||||
@@ -216,9 +346,12 @@ export const useContactStore = create<ContactStore>()(
|
||||
let sharedAccountId: string | undefined;
|
||||
for (const [bookId, value] of Object.entries(contact.addressBookIds)) {
|
||||
const book = books.find(b => b.id === bookId);
|
||||
if (book?.localAccountId) localAccountId = book.localAccountId;
|
||||
if (book?.isShared && book.originalId) {
|
||||
deNamespaced[book.originalId] = value;
|
||||
sharedAccountId = book.accountId;
|
||||
} else if (book?.originalId) {
|
||||
deNamespaced[book.originalId] = value;
|
||||
} else {
|
||||
deNamespaced[bookId] = value;
|
||||
}
|
||||
@@ -231,6 +364,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
client = resolveAccountClient(client, localAccountId);
|
||||
const created = await client.createContact(cleanedContact, accountId);
|
||||
// Preserve shared account metadata
|
||||
if (contact.isShared && contact.accountId) {
|
||||
@@ -255,8 +389,9 @@ export const useContactStore = create<ContactStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
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;
|
||||
client = resolveAccountClient(client, contact?.localAccountId);
|
||||
|
||||
// De-namespace addressBookIds for shared contacts before sending to JMAP server
|
||||
let cleanedUpdates = updates;
|
||||
@@ -288,8 +423,9 @@ export const useContactStore = create<ContactStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
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;
|
||||
client = resolveAccountClient(client, contact?.localAccountId);
|
||||
await client.deleteContact(originalId, accountId);
|
||||
set((state) => {
|
||||
const removedIds = new Set([id]);
|
||||
@@ -659,8 +795,9 @@ export const useContactStore = create<ContactStore>()(
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
client = resolveAccountClient(client, addressBook.localAccountId);
|
||||
await client.updateAddressBook(originalId, { name: trimmed }, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.map(b =>
|
||||
@@ -677,8 +814,9 @@ export const useContactStore = create<ContactStore>()(
|
||||
removeAddressBook: async (client, addressBook) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
client = resolveAccountClient(client, addressBook.localAccountId);
|
||||
await client.deleteAddressBook(originalId, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
|
||||
@@ -694,8 +832,9 @@ export const useContactStore = create<ContactStore>()(
|
||||
shareAddressBook: async (client, addressBook, principalId, rights) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
client = resolveAccountClient(client, addressBook.localAccountId);
|
||||
await client.setAddressBookShare(originalId, principalId, rights, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.map(b => {
|
||||
|
||||
Reference in New Issue
Block a user