Files
webmail/components/contacts/contacts-app.tsx
T

1466 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { ArrowLeft, Users, UsersRound, AlertTriangle, Plus, UserPlus, Upload, BookPlus } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ContactList } from "@/components/contacts/contact-list";
import { ContactDetail } from "@/components/contacts/contact-detail";
import { ContactForm } from "@/components/contacts/contact-form";
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { AddressBookPickerDialog } from "@/components/contacts/address-book-picker-dialog";
import { DirectoryBrowser, directoryKey } from "@/components/contacts/directory-browser";
import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-detail";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
import { inlineContactMedia, type ContactSaveOptions } from "@/lib/contact-media";
import { getContactPhotoClient } from "@/hooks/use-contact-photo";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
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 { useIsFocusedProTab } from "@/hooks/use-pane-context";
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";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links";
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useRegisterCommands } from "@/lib/command-registry";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
type View =
| "list"
| "detail"
| "create"
| "edit"
| "group-detail"
| "group-create"
| "group-edit"
| "bulk-add-to-group"
| "directory-detail";
export interface ContactsAppProps {
/** Path segments after `/contacts` (`['<contactId>', 'edit']`). */
linkSegments?: string[];
}
function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
const t = useTranslations("contacts");
const tSidebar = useTranslations("sidebar");
useDocumentTitle(tSidebar("contacts"));
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
contacts,
addressBooks,
selectedContactId,
searchQuery,
supportsSync,
contactsLoaded,
selectedContactIds,
setSelectedContact,
setSearchQuery,
fetchContacts,
createContact,
updateContact,
fetchContactFromServer,
deleteContact,
addLocalContact,
updateLocalContact,
deleteLocalContact,
getGroupMembers,
createGroup,
updateGroup,
addMembersToGroup,
removeMembersFromGroup,
deleteGroup,
toggleContactSelection,
selectRangeContacts,
selectAllContacts,
clearSelection,
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
createAddressBook,
renameAddressBook,
removeAddressBook,
shareAddressBook,
setDefaultAddressBook,
renameKeyword,
removeKeyword,
addKeyword,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
const [returnToEmail, setReturnToEmail] = useState(false);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
// "Move to…": the contacts waiting for an address book to be picked.
const [movingContactIds, setMovingContactIds] = useState<string[] | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// Who a new group starts with: the selection when it came from "Add to group".
const [newGroupMemberIds, setNewGroupMemberIds] = useState<string[]>([]);
const [directorySelection, setDirectorySelection] = useState<Set<string>>(() => new Set());
const [openDirectoryKey, setOpenDirectoryKey] = useState<string | null>(null);
const directoryPrincipals = useContactStore((s) => s.directoryPrincipals);
const showDirectory = !!client?.supportsPrincipals();
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// Mountain View's desktop chrome: its top bar in place of the left rail.
const mvShell = useMountainViewShell(isEmbedded);
const router = useRouter();
const searchParams = useSearchParams();
// One-shot intent flag: only consume the URL params on the first render that
// has them. After applying, we strip the query so a later refresh or
// re-mount doesn't re-trigger the navigation.
const intentAppliedRef = useRef(false);
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// A new view (a form, a group, a contact) replaces whatever the drawer was for.
useEffect(() => { setNarrowSidebarOpen(false); }, [view]);
// R5: an editor with unsaved changes asks before anything replaces it.
const [editorDirty, setEditorDirtyState] = useState(false);
const editorDirtyRef = useRef(false);
const setEditorDirty = useCallback((dirty: boolean) => {
editorDirtyRef.current = dirty;
setEditorDirtyState(dirty);
}, []);
useEffect(() => {
if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false);
}, [view, setEditorDirty]);
const confirmDiscard = useCallback(async () => {
if (!editorDirtyRef.current) return true;
const discard = await confirmDialog({
title: t("form.discard_title"),
message: t("form.discard_message"),
confirmText: t("form.discard"),
cancelText: t("form.keep_editing"),
variant: "destructive",
});
if (discard) setEditorDirty(false);
return discard;
}, [confirmDialog, t, setEditorDirty]);
const guard = <A extends unknown[]>(action: (...args: A) => unknown) => (...args: A): void => {
if (!editorDirtyRef.current) {
action(...args);
return;
}
void confirmDiscard().then((discard) => { if (discard) action(...args); });
};
// Leaving the page or going Back asks too.
useEffect(() => {
if (!editorDirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [editorDirty]);
const handleCancelRef = useRef<() => void>(() => {});
// Set when the app itself goes Back (to the email it came from): the
// guard's history entry is in the way then, and is stepped over.
const leavingRef = useRef(false);
const backGuard = useCloseOnBack(() => {
if (leavingRef.current) {
leavingRef.current = false;
window.history.back();
return;
}
void confirmDiscard().then((discard) => {
if (discard) handleCancelRef.current();
else backGuard.rearm();
});
}, editorDirty);
const routerBack = useCallback(() => {
if (backGuard.armed()) leavingRef.current = true;
router.back();
}, [backGuard, router]);
// Anything picked in the drawer closes it (R7).
const fromDrawer = <A extends unknown[], R>(action: (...args: A) => R) => (...args: A): R => {
setNarrowSidebarOpen(false);
return action(...args);
};
// Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
});
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
const sidebarDragStartWidth = useRef(256);
// Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
});
const [isListResizing, setIsListResizing] = useState(false);
const listDragStartWidth = useRef(384);
// Check auth on mount – skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
saveRedirectAfterLogin();
redirectToLogin();
}
}, [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, isEmbedded]);
// Apply the deep link once (#733): `/contacts/<id>[/edit]` and `/contacts/new`,
// plus the older one-shot query form the mobile recipient popover emits
// (`?contactId=`, `?addEmail=`). `from=email` flips the mobile back button to
// `router.back()`. After this the URL is an output of the view, not an input.
// The applier is held in a ref so the live-delivery subscription below (Pro
// shell) always calls the copy closing over the current setters.
const applyContactsDeepLink = (link: ContactDeepLink) => {
if (link.fromEmail) setReturnToEmail(true);
if (link.kind === 'contact') {
setSelectedContact(link.id);
setView(link.edit ? 'edit' : 'detail');
} else {
setCreatePrefill({ email: link.email, name: link.name });
setSelectedContact(null);
setView('create');
}
};
const applyContactsDeepLinkRef = useRef(applyContactsDeepLink);
applyContactsDeepLinkRef.current = applyContactsDeepLink;
useEffect(() => {
if (intentAppliedRef.current) return;
const entry = linkSegments ? { segments: linkSegments } : consumePendingDeepLinkEntry('contacts');
const segments = entry?.segments ?? [];
const link = parseContactsPath(
segments,
new URLSearchParams(entry?.search ?? searchParams.toString()),
);
intentAppliedRef.current = true;
if (!link) return;
applyContactsDeepLinkRef.current(link);
}, [searchParams, linkSegments, setSelectedContact]);
// Pro shell only: this surface stays mounted for the whole session, so links
// arriving after mount are delivered live instead of being parked forever.
// Embedded-only: during a cold-load redirect the standard instance renders
// briefly and must not steal the link parked for the Pro one.
useEffect(() => {
if (!isEmbedded) return;
return subscribePendingDeepLink('contacts', (segments, search) => {
const link = parseContactsPath(segments, new URLSearchParams(search ?? window.location.search));
if (link) applyContactsDeepLinkRef.current(link);
});
}, [isEmbedded]);
// The permalink for the contact on screen. In the Pro shell only the
// focused tab writes the address bar; the standard instance that renders
// while Pro takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
const isFocusedProTab = useIsFocusedProTab();
const contactsLinkPath = appPath(buildContactsPath({
contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
editing: view === 'edit',
}));
useDeepLinkUrl(
isEmbedded
? (isFocusedProTab ? contactsLinkPath : null)
: proInterfaceActive ? null : contactsLinkPath,
);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page.
const { indicator: refreshIndicator } = useRefreshGesture({
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),
fetchAllAccountsContacts(accountClients),
]);
return;
}
}
await fetchContacts(client);
},
});
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
// Collect all unique keywords across contacts
const allKeywords = useMemo(() => {
const kws = new Set<string>();
for (const contact of individuals) {
if (!contact.keywords) continue;
for (const [kw, active] of Object.entries(contact.keywords)) {
if (active) kws.add(kw);
}
}
return Array.from(kws).sort((a, b) => a.localeCompare(b));
}, [individuals]);
// Contacts to display based on active category
const displayedContacts = useMemo(() => {
if (activeCategory === "all" || activeCategory === "directory") return individuals;
if (activeCategory === "uncategorized") {
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
}
if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId;
return individuals.filter(c => {
if (!c.addressBookIds) return false;
return c.addressBookIds[bookId] === true;
});
}
if ("keyword" in activeCategory) {
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
}
// Show members of the selected group
return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]);
const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category);
clearSelection();
setDirectorySelection(new Set());
if (typeof category === "object" && "groupId" in category) {
setSelectedGroupId(category.groupId);
setView("group-detail");
} else if (category === "directory") {
setSelectedGroupId(null);
setOpenDirectoryKey(null);
setView("list");
} else {
setSelectedGroupId(null);
}
setNarrowSidebarOpen(false);
}, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
if (!client) return;
try {
await moveContactToAddressBook(client, contactIds, addressBook);
const msg = contactIds.length === 1
? t("address_books.moved", { name: addressBook.name })
: t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name });
toast.success(msg);
} catch (error) {
console.error('Failed to move contacts:', error);
toast.error(t("address_books.move_failed"));
}
}, [client, moveContactToAddressBook, t]);
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
const handleMoveContacts = useCallback((ids: string[]) => {
if (ids.length > 0) setMovingContactIds(ids);
}, []);
// The book the contacts are in, when they share one, so the picker can leave it out.
const movingFromBookId = useMemo(() => {
if (!movingContactIds) return null;
const books = new Set(movingContactIds.flatMap((id) => {
const c = contacts.find((x) => x.id === id);
return c?.addressBookIds ? Object.keys(c.addressBookIds).filter((k) => c.addressBookIds[k]) : [];
}));
return books.size === 1 ? [...books][0] : null;
}, [movingContactIds, contacts]);
// Deleting a category only takes a label off contacts: no question, an Undo (R3).
const handleDeleteKeyword = useCallback(async (keyword: string) => {
const jmapClient = supportsSync && client ? client : null;
try {
const ids = await removeKeyword(jmapClient, keyword);
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
setActiveCategory("all");
}
toast.success(t("category_deleted", { name: keyword }), {
action: {
label: t("undo"),
onClick: () => {
void addKeyword(jmapClient, keyword, ids).catch(() => toast.error(t("toast.error_update")));
},
},
});
} catch (error) {
console.error("Failed to delete category:", error);
toast.error(t("category_delete_failed"));
}
}, [supportsSync, client, removeKeyword, addKeyword, activeCategory, t]);
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
if (!client && supportsSync) return;
try {
// Contacts in the category afterwards; a plugin may stop some updates.
let added = 0;
for (const contactId of contactIds) {
const contact = contacts.find(c => c.id === contactId);
if (!contact) continue;
const existingKeywords = contact.keywords || {};
if (existingKeywords[keyword]) { added++; continue; } // already has this keyword
const updatedKeywords = { ...existingKeywords, [keyword]: true };
const updated = supportsSync && client
? await updateContact(client, contactId, { keywords: updatedKeywords })
: await updateLocalContact(contactId, { keywords: updatedKeywords });
if (updated !== false) added++;
}
if (added === 0) return;
const msg = added === 1
? t("category_added", { name: keyword })
: t("category_added_plural", { count: added, name: keyword });
toast.success(msg);
} catch (error) {
console.error('Failed to add contacts to category:', error);
toast.error(t("toast.error_update"));
}
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
// Refresh address books (and contacts) after a structural change, staying
// multi-account aware so a freshly created book lands in the sidebar.
const refreshAddressBooks = useCallback(async () => {
if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
await fetchAllAccountsAddressBooks(accountClients);
return;
}
}
await useContactStore.getState().fetchAddressBooks(client);
}, [client, multiAccountEnabled, accountClients]);
const handleCreateAddressBook = useCallback(async (name: string) => {
if (!client) return;
try {
await createAddressBook(client, name);
await refreshAddressBooks();
toast.success(t("address_books.created"));
} catch (error) {
console.error('Failed to create address book:', error);
toast.error(t("address_books.create_failed"));
}
}, [client, createAddressBook, refreshAddressBooks, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
const handleSelectContact = (id: string) => {
setSelectedContact(id);
clearSelection();
setView("detail");
};
const handleCreateNew = () => {
setSelectedContact(null);
// When a specific address book is being viewed, create the new contact in
// that book instead of falling back to the account's default book.
if (typeof activeCategory === "object" && "addressBookId" in activeCategory) {
setDefaultBookIdForCreate(activeCategory.addressBookId);
} else {
setDefaultBookIdForCreate(undefined);
}
setView("create");
};
const handleEdit = () => {
setView("edit");
};
const deleteContactById = useCallback(async (contactId: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
const deleted = supportsSync && client
? await deleteContact(client, contactId)
: await deleteLocalContact(contactId);
// A plugin stopped it and has told the user why.
if (!deleted) return;
toast.success(t("toast.deleted"));
if (selectedContactId === contactId) setView("list");
} catch (error) {
console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete"));
}
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
const handleDelete = async () => {
if (!selectedContact) return;
await deleteContactById(selectedContact.id);
};
const handleEditContact = useCallback((id: string) => {
setSelectedContact(id);
setView("edit");
}, [setSelectedContact]);
const handleDeleteContact = useCallback((contact: ContactCard) => {
void deleteContactById(contact.id);
}, [deleteContactById]);
const handleAddContactToGroup = useCallback((id: string) => {
clearSelection();
toggleContactSelection(id);
if (groups.length === 0) {
// No group to add to yet: a new one, with the contact already in it.
setNewGroupMemberIds([id]);
setView("group-create");
return;
}
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
// After a save or a duplicate the new card is on screen, not "Select a contact".
const showNewestContact = useCallback(() => {
const newest = useContactStore.getState().contacts.at(-1);
if (!newest) {
setView("list");
return;
}
setSelectedContact(newest.id);
setView("detail");
}, [setSelectedContact]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
void _id; void _uid; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client && source.media) {
// A blobId names the bytes embedded in the source card; the copy gets
// the bytes themselves.
data.media = await inlineContactMedia(source.media, {
client: getContactPhotoClient(source.localAccountId),
accountId: source.accountId,
localAccountId: source.localAccountId,
}, "keep");
}
const created = supportsSync && client
? await createContact(client, data)
: await addLocalContact({
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
});
if (!created) return;
toast.success(t("toast.created"));
showNewestContact();
}, [supportsSync, client, createContact, addLocalContact, t, showNewestContact]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>, options?: ContactSaveOptions) => {
const created = supportsSync && client
? await createContact(client, data, options)
: await addLocalContact({
id: `local-${generateUUID()}`,
addressBookIds: {},
...data,
});
// A plugin stopped it: keep the form open with what was typed.
if (!created) return;
toast.success(t("toast.created"));
setEditorDirty(false);
setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
setReturnToEmail(false);
routerBack();
return;
}
showNewestContact();
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, routerBack, setEditorDirty, showNewestContact]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>, options?: ContactSaveOptions) => {
if (!selectedContact) return;
const updated = supportsSync && client
? await updateContact(client, selectedContact.id, data, options)
: await updateLocalContact(selectedContact.id, data);
if (!updated) return;
toast.success(t("toast.updated"));
setView("detail");
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
setEditorDirty(false);
setDefaultBookIdForCreate(undefined);
// Came from email → cancel returns to the email instead of the contact list.
if (returnToEmail && view === "create") {
setCreatePrefill(undefined);
setReturnToEmail(false);
routerBack();
return;
}
if (view === "create") setCreatePrefill(undefined);
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") {
setView("list");
} else {
setView(selectedContact ? "detail" : "list");
}
};
handleCancelRef.current = handleCancel;
const _handleSelectGroup = (id: string) => {
setSelectedGroupId(id);
setActiveCategory({ groupId: id });
setView("group-detail");
};
const handleCreateGroup = () => {
setSelectedGroupId(null);
setNewGroupMemberIds([]);
setView("group-create");
};
// "Add to group": a new group starting with the selected contacts.
const handleCreateGroupFromSelection = () => {
setSelectedGroupId(null);
setNewGroupMemberIds(Array.from(selectedContactIds));
setView("group-create");
};
const handleEditGroup = () => {
setView("group-edit");
};
const handleEditGroupFromSidebar = useCallback((groupId: string) => {
setSelectedGroupId(groupId);
setActiveCategory({ groupId });
setView("group-edit");
}, []);
// Open the in-app composer in the current session rather than routing through
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
// (which may open a different mail app), and the mailto protocol round-trip
// reloads the app - dropping the in-memory per-account JMAP clients of a
// multi-account session, which reads as a logout. Stashing the recipients and
// doing a client-side router.push keeps the session and the active account
// intact; the main route consumes the pending compose and opens the composer
// (see consumePendingMailto in page.tsx).
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
savePendingMailto({
to: field === "to" ? recipients : [],
cc: field === "cc" ? recipients : [],
bcc: field === "bcc" ? recipients : [],
subject: "",
body: "",
});
router.push("/");
}, [router]);
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
// Hand the composer a single group chip (RFC 5322 group syntax survives
// the string hand-off) instead of one entry per member - the chip expands
// into the members when the message is sent. Dedupe by email,
// case-insensitively; members without an email are skipped.
const seen = new Set<string>();
const members: Array<{ name?: string; email: string }> = [];
for (const member of getGroupMembers(groupId)) {
const email = getContactPrimaryEmail(member).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
const name = getContactDisplayName(member);
members.push({ name: name && name !== email ? name : undefined, email });
}
if (members.length === 0) {
toast.error(t("groups.no_member_emails"));
return;
}
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
const chip: Recipient = {
name: (group && getContactDisplayName(group)) || "Group",
email: "",
group: { members },
};
openComposeInApp([formatRecipientEntry(chip)], field);
}, [getGroupMembers, t, openComposeInApp]);
const handleComposeDirectory = useCallback((entries: DirectoryEntry[]) => {
if (entries.length === 0) return;
openComposeInApp(entries.map((e) => formatRecipient(e.name !== e.email ? e.name : "", e.email)), "to");
}, [openComposeInApp]);
const handleAddDirectoryEntryToContacts = useCallback((entry: DirectoryEntry) => {
setCreatePrefill({ email: entry.email, name: entry.name !== entry.email ? entry.name : undefined });
setSelectedContact(null);
setDefaultBookIdForCreate(undefined);
setActiveCategory("all");
setView("create");
}, [setSelectedContact]);
const handleComposeContact = useCallback((contact: ContactCard) => {
const email = getContactPrimaryEmail(contact).trim();
if (!email) return;
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
}, [openComposeInApp]);
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
message: t("groups.delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteGroup(supportsSync && client ? client : null, groupId);
toast.success(t("toast.deleted"));
if (selectedGroupId === groupId) {
setSelectedGroupId(null);
setActiveCategory("all");
setView("list");
}
} catch (error) {
console.error('Failed to delete group:', error);
toast.error(t("toast.error_delete"));
}
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
const handleDeleteGroup = async () => {
if (!selectedGroup) return;
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
message: t("groups.delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
toast.success(t("toast.deleted"));
setSelectedGroupId(null);
setView("list");
} catch (error) {
console.error('Failed to delete group:', error);
toast.error(t("toast.error_delete"));
}
};
const handleSaveGroup = useCallback(async (name: string, memberIds: string[]) => {
const jmapClient = supportsSync && client ? client : null;
if (view === "group-edit" && selectedGroup) {
await updateGroup(jmapClient, selectedGroup.id, name);
// Use resolved member contact IDs for diff, not raw urn:uuid: keys
const currentIds = selectedGroupMembers.map(m => m.id);
const toAdd = memberIds.filter(id => !currentIds.includes(id));
const toRemove = currentIds.filter(id => !memberIds.includes(id));
if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd);
if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove);
toast.success(t("toast.updated"));
setView("group-detail");
} else {
await createGroup(jmapClient, name, memberIds);
toast.success(t("toast.created"));
clearSelection();
setView("list");
}
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, clearSelection, t]);
const handleRemoveGroupMember = async (memberId: string) => {
if (!selectedGroup) return;
try {
await removeMembersFromGroup(
supportsSync && client ? client : null,
selectedGroup.id,
[memberId]
);
toast.success(t("toast.updated"));
} catch (error) {
console.error('Failed to remove group member:', error);
toast.error(t("toast.error_update"));
}
};
const handleBulkDelete = async () => {
if (selectedContactIds.size === 0) return;
const confirmed = await confirmDialog({
title: t("bulk.delete_confirm_title"),
message: t("bulk.delete_confirm", { count: selectedContactIds.size }),
confirmText: t("bulk.delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (!(await bulkDeleteContacts(
supportsSync && client ? client : null,
Array.from(selectedContactIds)
))) return;
toast.success(t("bulk.deleted", { count: selectedContactIds.size }));
setView("list");
} catch (error) {
console.error('Failed to bulk delete contacts:', error);
toast.error(t("toast.error_delete"));
}
};
const handleBulkAddToGroup = () => {
if (selectedContactIds.size === 0) return;
if (groups.length === 0) {
handleCreateGroupFromSelection();
return;
}
setView("bulk-add-to-group");
};
const handleBulkExport = async () => {
const toExport = contacts.filter(c => selectedContactIds.has(c.id));
if (toExport.length > 0 && await exportContacts(toExport)) {
toast.success(t("export.success", { count: toExport.length }));
clearSelection();
}
};
// The command palette's Contacts group (Ctrl/⌘+K).
useRegisterCommands(tSidebar("contacts"), [
{ id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() },
{ id: "import", label: t("import.title"), icon: Upload, run: () => setShowImportDialog(true) },
{ id: "new-group", label: t("groups.create"), icon: UsersRound, run: () => guard(handleCreateGroup)() },
{ id: "new-address-book", label: t("address_books.create"), icon: BookPlus, run: () => setCreatingAddressBook(true), when: () => !!client },
], contactsEnabled);
const handleBulkAddToGroupConfirm = async (groupId: string) => {
try {
await bulkAddToGroup(
supportsSync && client ? client : null,
groupId,
Array.from(selectedContactIds)
);
toast.success(t("bulk.added_to_group"));
setView("list");
} catch (error) {
console.error('Failed to add contacts to group:', error);
toast.error(t("toast.error_update"));
}
};
if (!isAuthenticated) return null;
const renderRightPanel = () => {
// The directory is read-only: its panel shows the open entry whatever the view.
if (activeCategory === "directory") {
return (
<DirectoryEntryDetail
entry={directoryPrincipals.find((e) => directoryKey(e) === openDirectoryKey) ?? null}
onCompose={(entry) => handleComposeDirectory([entry])}
onAddToContacts={guard(handleAddDirectoryEntryToContacts)}
/>
);
}
switch (view) {
case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={guard(handleCancel)} onDirtyChange={setEditorDirty} />;
case "edit":
if (!selectedContact) return null;
return (
<ContactForm
contact={selectedContact}
addressBooks={addressBooks}
allKeywords={allKeywords}
onSave={handleSaveEdit}
onCancel={guard(handleCancel)}
onDirtyChange={setEditorDirty}
loadCurrentMedia={supportsSync && client ? () => fetchContactFromServer(client, selectedContact.id) : undefined}
/>
);
case "group-detail":
if (!selectedGroup) return null;
return (
<ContactGroupDetail
group={selectedGroup}
members={selectedGroupMembers}
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember}
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
isMobile={isMobile}
onSelectMember={guard((id: string) => {
setSelectedContact(id);
setView("detail");
})}
/>
);
case "group-create":
return (
<ContactGroupForm
individuals={individuals}
currentMemberIds={newGroupMemberIds}
onSave={handleSaveGroup}
onCancel={guard(handleCancel)}
onDirtyChange={setEditorDirty}
/>
);
case "group-edit":
if (!selectedGroup) return null;
return (
<ContactGroupForm
group={selectedGroup}
individuals={individuals}
currentMemberIds={selectedGroupMembers.map(m => m.id)}
onSave={handleSaveGroup}
onCancel={guard(handleCancel)}
onDirtyChange={setEditorDirty}
/>
);
case "bulk-add-to-group":
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("bulk.choose_group")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("bulk.adding_contacts", { count: selectedContactIds.size })}
</p>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border">
<button
type="button"
onClick={handleCreateGroupFromSelection}
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<UsersRound className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0 text-sm font-medium">{t("bulk.new_group")}</div>
</button>
{groups.map((group) => {
const gName = getContactDisplayName(group);
const memberCount = group.members
? Object.values(group.members).filter(Boolean).length
: 0;
return (
<button
key={group.id}
onClick={() => handleBulkAddToGroupConfirm(group.id)}
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{gName}</div>
<div className="text-xs text-muted-foreground">
{t("groups.member_count", { count: memberCount })}
</div>
</div>
</button>
);
})}
</div>
<div className="px-6 py-4 border-t border-border">
<Button variant="outline" onClick={handleCancel} className="w-full">
{t("form.cancel")}
</Button>
</div>
</div>
);
default:
return (
<ContactDetail
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
onCompose={
selectedContact
? () => handleComposeContact(selectedContact)
: undefined
}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
: undefined
}
onDuplicate={
selectedContact
? () => void handleDuplicateContact(selectedContact)
: undefined
}
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
isMobile={isMobile}
/>
);
}
};
if (!contactsEnabled) {
return (
<div className="flex h-dvh items-center justify-center bg-background p-6">
<div className="max-w-lg text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-amber-600 dark:text-amber-400 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
);
}
const showListPanel = !isMobile || view === "list";
const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => {
if (returnToEmail) {
setReturnToEmail(false);
setCreatePrefill(undefined);
routerBack();
return;
}
setView("list");
clearSelection();
};
return (
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
{refreshIndicator}
{mvShell && (
<MvTopBar
showMenuButton={false}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<div data-mv-card={mvShell ? "" : undefined} className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col", mvShell && "mb-4 mx-4")}>
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && !mvShell && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
{/* Clips the closed drawer, so it never sits over the nav rail. */}
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{showListPanel && (
<>
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{(!isMobile || isNarrow) && (
<>
<div
data-testid={isNarrow ? "contacts-sidebar-drawer" : undefined}
className={cn(
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-[transform,visibility] duration-300 ease-in-out",
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full"
)
)}
// Closed, the drawer is out of reach: no clicks, no Tab stops.
inert={isNarrow && !narrowSidebarOpen}
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
>
<ContactsSidebar
groups={groups}
individuals={individuals}
addressBooks={addressBooks}
activeCategory={activeCategory}
onSelectCategory={guard(handleSelectCategory)}
onCreateGroup={fromDrawer(guard(handleCreateGroup))}
onCreateContact={fromDrawer(guard(handleCreateNew))}
onCreateAddressBook={client ? fromDrawer(() => setCreatingAddressBook(true)) : undefined}
onImport={fromDrawer(() => setShowImportDialog(true))}
onEditGroup={fromDrawer(guard(handleEditGroupFromSidebar))}
onDeleteGroup={fromDrawer(handleDeleteGroupFromSidebar)}
onComposeGroup={fromDrawer(handleComposeGroupFromSidebar)}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? fromDrawer((book: AddressBook) => setRenamingAddressBook(book)) : undefined}
onShareAddressBook={client ? fromDrawer((book: AddressBook) => setSharingAddressBookId(book.id)) : undefined}
onSetDefaultAddressBook={client ? fromDrawer(async (book: AddressBook) => {
try {
await setDefaultAddressBook(client, book);
toast.success(t("address_books.default_updated"));
} catch {
toast.error(t("address_books.set_default_failed"));
}
}) : undefined}
onCreateContactInBook={fromDrawer(guard((book: AddressBook) => {
setDefaultBookIdForCreate(book.id);
setSelectedContact(null);
setView("create");
}))}
onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => {
const ok = await confirmDialog({
title: t("address_books.delete"),
message: t("address_books.confirm_delete", { name: book.name }),
variant: "destructive",
confirmText: t("address_books.delete"),
});
if (!ok) return;
try {
await removeAddressBook(client, book);
toast.success(t("address_books.deleted"));
} catch {
toast.error(t("address_books.delete_failed"));
}
}) : undefined}
onRenameKeyword={fromDrawer((kw: string) => setRenamingKeyword(kw))}
onDeleteKeyword={fromDrawer((kw: string) => void handleDeleteKeyword(kw))}
directoryCount={showDirectory ? directoryPrincipals.length : undefined}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
</div>
{!isNarrow && (
<ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsSidebarResizing(false);
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
)}
</>
)}
{/* Panel 2: Contact list */}
<div
data-tour="contacts-list"
className={cn(
"relative border-e border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
!isListResizing && !isMobile && "transition-[width] duration-300"
)}
style={!isMobile ? { width: `${listWidth}px` } : undefined}
>
{activeCategory === "directory" ? (
<>
<DirectoryBrowser
client={client}
selected={directorySelection}
onSelectedChange={setDirectorySelection}
onOpen={(entry) => { setOpenDirectoryKey(directoryKey(entry)); setView("directory-detail"); }}
activeKey={openDirectoryKey}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
className="flex-1"
/>
{directorySelection.size > 0 && (
<div className="flex items-center gap-2 px-3 py-2 border-t border-border">
<span className="text-xs text-muted-foreground me-auto tabular-nums">
{t("directory.selected_count", { count: directorySelection.size })}
</span>
<Button size="sm" variant="ghost" onClick={() => setDirectorySelection(new Set())}>
{t("bulk.clear")}
</Button>
<Button
size="sm"
onClick={() => handleComposeDirectory(directoryPrincipals.filter((e) => directorySelection.has(directoryKey(e))))}
>
{t("directory.write_email")}
</Button>
</div>
)}
</>
) : (
<ContactList
contacts={displayedContacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={guard(handleSelectContact)}
onCreateNew={guard(handleCreateNew)}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
onSelectRangeContacts={selectRangeContacts}
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={guard(handleBulkAddToGroup)}
onBulkExport={handleBulkExport}
onEditContact={guard(handleEditContact)}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={guard(handleAddContactToGroup)}
onComposeContact={handleComposeContact}
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
isLoading={supportsSync && !contactsLoaded}
/>
)}
{/* The phone's create button, as in Mail and Files (R7). */}
{isMobile && activeCategory !== "directory" && selectedContactIds.size === 0 && (
<Button
size="icon-lg"
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
aria-label={t("create_new")}
title={t("create_new")}
onClick={guard(handleCreateNew)}
>
<Plus className="w-5 h-5" />
</Button>
)}
</div>
{!isMobile && (
<ResizeHandle
onResizeStart={() => { listDragStartWidth.current = listWidth; setIsListResizing(true); }}
onResize={(delta) => setListWidth(Math.max(220, Math.min(500, listDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsListResizing(false);
localStorage.setItem("contacts-list-width", String(listWidth));
}}
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
/>
)}
</>
)}
{/* Panel 3: Detail / Form */}
{showRightPanel && (
<div className="flex-1 min-w-0 flex flex-col">
{isMobile && (
<div className="px-3 py-2 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={guard(mobileBackToList)}
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 me-2" />
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button>
</div>
)}
<div className="flex-1 min-h-0">
{renderRightPanel()}
</div>
</div>
)}
</div>
{isMobile && !isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
</div>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<PromptDialog
isOpen={renamingKeyword !== null}
onClose={() => setRenamingKeyword(null)}
title={t("rename_category")}
placeholder={t("category_name_label")}
defaultValue={renamingKeyword ?? ""}
confirmText={t("form.save")}
cancelText={t("form.cancel")}
onSubmit={(newName) => {
const keyword = renamingKeyword;
if (keyword === null || newName === keyword) return;
void (async () => {
try {
await renameKeyword(supportsSync && client ? client : null, keyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
setActiveCategory({ keyword: newName });
}
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
})();
}}
/>
<PromptDialog
isOpen={creatingAddressBook}
onClose={() => setCreatingAddressBook(false)}
title={t("address_books.create")}
placeholder={t("address_books.name_label")}
confirmText={t("address_books.create_confirm")}
cancelText={t("form.cancel")}
onSubmit={(name) => void handleCreateAddressBook(name)}
/>
<PromptDialog
isOpen={renamingAddressBook !== null}
onClose={() => setRenamingAddressBook(null)}
title={t("address_books.rename")}
placeholder={t("address_books.name_label")}
defaultValue={renamingAddressBook?.name ?? ""}
confirmText={t("form.save")}
cancelText={t("form.cancel")}
onSubmit={(newName) => {
const book = renamingAddressBook;
if (!client || !book || newName === book.name) return;
void (async () => {
try {
await renameAddressBook(client, book, newName);
toast.success(t("address_books.renamed"));
} catch (err) {
console.error("Failed to rename address book:", err);
toast.error(t("address_books.rename_failed"));
}
})();
}}
/>
<AddressBookPickerDialog
isOpen={movingContactIds !== null}
onClose={() => setMovingContactIds(null)}
addressBooks={writableBooks}
currentId={movingFromBookId}
count={movingContactIds?.length ?? 0}
onPick={(book) => {
if (movingContactIds) void handleDropContacts(movingContactIds, book);
}}
/>
{showImportDialog && (
<ContactImportDialog
existingContacts={contacts}
onImport={handleImportContacts}
onClose={() => setShowImportDialog(false)}
/>
)}
{sharingAddressBookId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingAddressBookId(null)}
/>
);
})()}
</div>
</div>
);
}
// useSearchParams() above needs a Suspense boundary for the surface to
// prerender (static Lite build); at runtime it never suspends.
export function ContactsApp(props: ContactsAppProps = {}) {
return (
<Suspense fallback={null}>
<ContactsAppContent {...props} />
</Suspense>
);
}