Files
native/src/stores/contacts-store.ts
T

1184 lines
49 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createPersistStorage, memoizeSlice } from './persist-storage';
import type { ContactCard, AddressBook, StateChange, EmailAddress } from '../api/types';
import {
getAddressBooks as fetchPrimaryAddressBooks,
getAllAddressBooks as fetchAllAddressBooks,
queryContacts,
getContacts as fetchContactsByIds,
getAllContacts as fetchAllContacts,
createContact as apiCreateContact,
updateContact as apiUpdateContact,
deleteContacts as apiDeleteContacts,
createAddressBook as apiCreateAddressBook,
updateAddressBook as apiUpdateAddressBook,
deleteAddressBook as apiDeleteAddressBook,
setDefaultAddressBook as apiSetDefaultAddressBook,
getContactsInBook,
getContactsAccountId,
getContactCapableAccountIds,
getContactMedia as apiGetContactMedia,
namespaceId,
stripClientFields,
} from '../api/contacts';
import { inlineContactMedia, writeContactWithNewPhoto, type ContactSaveOptions } from '../lib/contact-media';
import { queryRecentRecipients, searchSentRecipients, type RecentRecipient } from '../api/recent-recipients';
import { jmapClient } from '../api/jmap-client';
import {
getContactDisplayName,
getContactKeywords,
getContactPrimaryEmail,
getContactSortName,
isGroup,
matchesContactSearch,
} from '../lib/contact-utils';
import { sanitizeDisplayName, splitMailbox } from '../lib/rfc5322-mailbox';
import { requestDeviceSync } from '../device-sync/app/request';
import { CONTACTS_AUTHORITY } from '../device-sync/types';
const SELECTED_CATEGORY_STORAGE_KEY = 'webmail:contacts:category:v1';
// Dedicated JMAP address book that backs the "trusted senders" allow-list.
// Mirrors the webmail so the same book is shared across clients.
export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
export type ContactCategory =
| { type: 'all' }
| { type: 'addressBook'; addressBookId: string }
| { type: 'group'; groupId: string }
| { type: 'keyword'; keyword: string }
| { type: 'uncategorized' };
/** One compose-autocomplete suggestion: a person, or a contact group. */
export interface RecipientSuggestion {
name: string;
email: string;
group?: { id: string; memberCount: number };
}
export interface ContactsState {
addressBooks: AddressBook[];
contacts: ContactCard[];
selectedCategory: ContactCategory;
loading: boolean;
error: string | null;
hydrated: boolean;
/** When `contacts` last got every card from the server (0 = not this session). */
contactsFetchedAt: number;
// Trusted senders are stored as contacts in a dedicated JMAP address book so
// the allow-list syncs across devices (matches the webmail behavior).
trustedSendersBookId: string | null;
trustedSenderEmails: string[];
trustedSendersLoaded: boolean;
trustedSendersLoading: boolean;
// People the user has written to (scanned from the Sent folder) for the
// composer autocomplete.
recentRecipients: RecentRecipient[];
recentRecipientsLoaded: boolean;
sentMailboxId: string | null;
hydrate: () => Promise<void>;
fetchAddressBooks: () => Promise<void>;
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
/** `fetchContacts()` unless every card was fetched within `CONTACTS_STALE_MS`. */
fetchContactsIfStale: () => Promise<void>;
refresh: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>;
/** `options.photoKey` names the `media` entry holding a newly picked photo. */
createContact: (
contact: Partial<ContactCard>,
addressBookId: string,
options?: ContactSaveOptions,
) => Promise<ContactCard>;
updateContact: (id: string, changes: Partial<ContactCard>, options?: ContactSaveOptions) => Promise<void>;
/** The card's `media` as the server has it now, or null when it can't be read. */
fetchContactMedia: (id: string) => Promise<Pick<ContactCard, 'media'> | null>;
deleteContact: (id: string) => Promise<void>;
bulkDelete: (ids: string[]) => Promise<void>;
/**
* Creates the cards in the book; one whose UID the account already has is
* updated in place instead (a re-import of an export).
*/
importContacts: (
contacts: Partial<ContactCard>[],
addressBookId: string,
) => Promise<ContactImportResult>;
addContactKeyword: (id: string, keyword: string) => Promise<void>;
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
renameKeyword: (oldKeyword: string, newKeyword: string) => Promise<void>;
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
// Groups
addContactsToGroup: (groupId: string, contactIds: string[]) => Promise<void>;
createGroup: (name: string, memberIds: string[], addressBookId?: string) => Promise<ContactCard>;
/** Member addresses of a group, one entry per address (case-insensitive). */
getGroupRecipients: (groupId: string) => EmailAddress[];
// Address book management
createAddressBook: (name: string) => Promise<AddressBook>;
renameAddressBook: (id: string, name: string) => Promise<void>;
deleteAddressBook: (id: string) => Promise<void>;
setDefaultAddressBook: (id: string) => Promise<void>;
/** The book new contacts / imports should land in when none is chosen. */
getDefaultAddressBookId: () => string | null;
// Composer autocomplete
loadRecentRecipients: (sentMailboxId?: string | null) => Promise<void>;
searchRecipients: (query: string) => Promise<RecipientSuggestion[]>;
getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[];
findContactByEmail: (email: string) => ContactCard | undefined;
// Trusted senders address book. Passive loads (createIfMissing=false) only
// read an existing book; the book is created lazily on the first add.
loadTrustedSendersBook: (createIfMissing?: boolean) => Promise<void>;
addToTrustedSendersBook: (email: string) => Promise<void>;
removeFromTrustedSendersBook: (email: string) => Promise<void>;
isTrustedAddressBookSender: (email: string) => boolean;
setSelectedCategory: (category: ContactCategory) => void;
reset: () => void;
}
/** A card an import could not save, and the server's reason. */
export interface ContactImportFailure {
name: string;
reason: string;
}
export interface ContactImportResult {
imported: number;
/** Cards whose UID the account already had, updated in place. */
updated: number;
failed: number;
failures: ContactImportFailure[];
}
/**
* The card a create was refused for because its UID is taken, as Stalwart
* names it (0.16 and 1.0): "Contact with UID urn:uuid:... already exists
* with id b."
*/
export function existingUidCardId(err: unknown): string | null {
if (!(err instanceof Error)) return null;
const m = /already exists with id ([^\s.]+)/i.exec(err.message);
return m ? m[1] : null;
}
function persistCategory(category: ContactCategory): void {
void AsyncStorage.setItem(SELECTED_CATEGORY_STORAGE_KEY, JSON.stringify(category)).catch(
(err) => console.warn('[contacts-store] persist category failed', err),
);
}
/** Every e-mail address held by the given contacts, lowercased and de-duplicated. */
export function collectContactEmails(contacts: ContactCard[]): string[] {
const seen = new Set<string>();
for (const contact of contacts) {
if (!contact.emails) continue;
for (const entry of Object.values(contact.emails)) {
const address = entry.address?.toLowerCase().trim();
if (address) seen.add(address);
}
}
return Array.from(seen);
}
/** Parse "Name <email>" (or a bare address) into its parts. */
function parseTrustedSenderInput(input: string): { name?: string; email: string } {
const trimmed = input.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
const name = angleMatch ? angleMatch[1].trim() : undefined;
const email = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim();
return { name: name || undefined, email };
}
/**
* Strip a deleted card's id / uid / `urn:uuid:` variants from every group's
* member map so drawer counts and group views stop counting it until the next
* full refresh (webmail cleanGroupMembers).
*/
export function cleanGroupMembers(contacts: ContactCard[], removedIds: Set<string>): ContactCard[] {
const removedKeys = new Set<string>();
for (const c of contacts) {
if (!removedIds.has(c.id)) continue;
removedKeys.add(c.id);
if (c.uid) {
removedKeys.add(c.uid);
removedKeys.add(c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid);
}
if (c.originalId) removedKeys.add(c.originalId);
}
if (removedKeys.size === 0) return contacts;
return contacts.map((c) => {
if (c.kind !== 'group' || !c.members) return c;
let changed = false;
const newMembers: Record<string, boolean> = {};
for (const [key, val] of Object.entries(c.members)) {
const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key;
if (removedKeys.has(key) || removedKeys.has(bareKey)) {
changed = true;
} else {
newMembers[key] = val;
}
}
return changed ? { ...c, members: newMembers } : c;
});
}
/**
* Reduces every suggestion to a clean display name plus a bare address and
* collapses the ones that resolve to the same address (webmail #672). Groups
* carry no address and pass through untouched.
*/
export function normalizeSuggestions(results: RecipientSuggestion[]): RecipientSuggestion[] {
const out: RecipientSuggestion[] = [];
const indexByEmail = new Map<string, number>();
for (const r of results) {
if (r.group) {
out.push(r);
continue;
}
const mailbox = splitMailbox(r.email);
if (!mailbox.email) continue;
const name = sanitizeDisplayName(r.name) || mailbox.name || '';
const suggestion = { name: name === mailbox.email ? '' : name, email: mailbox.email };
const key = mailbox.email.toLowerCase();
const seenAt = indexByEmail.get(key);
if (seenAt === undefined) {
indexByEmail.set(key, out.length);
out.push(suggestion);
} else if (!out[seenAt].name && suggestion.name) {
out[seenAt].name = suggestion.name;
}
}
return out;
}
// In-flight loads shared by every caller so a cold start cannot fire two
// AddressBook/get or ContactCard/query requests at once (Stalwart mints its
// default collections lazily on first touch, #907) and a "trust sender" tap
// never races the passive trusted-senders load.
let addressBooksPromise: Promise<void> | null = null;
let contactsPromise: Promise<void> | null = null;
let trustedSendersPromise: Promise<void> | null = null;
// One up with every reset (an account switch, a sign-out), as in the calendar
// store. The client is one singleton that serves whichever account is active,
// so a load or a write still under way from before must keep what it read to
// itself: landing after the switch, it put the previous account's cards and
// books under the next one, and a loop of writes went on in the new account.
let storeGeneration = 0;
/** The store was reset (another account) while a contacts write was under way. */
export class ContactsAccountChangedError extends Error {
constructor() {
super('The account changed while the contacts were being saved');
this.name = 'ContactsAccountChangedError';
}
}
/** For one load or write: whether a reset has overtaken it since it started. */
function generationGuard() {
const generation = storeGeneration;
const stale = () => generation !== storeGeneration;
return {
stale,
/** Throws ContactsAccountChangedError once stale: nothing more may be sent or kept. */
check: () => {
if (stale()) throw new ContactsAccountChangedError();
},
};
}
// Startup fetches every card and live `ContactCard` state changes refetch
// them, so opening Contacts only downloads the cards again once the last full
// fetch is this old - it covers changes missed while the app was in the
// background or live updates were down (PF6).
export const CONTACTS_STALE_MS = 5 * 60_000;
export const useContactsStore = create<ContactsState>()(
persist(
(set, get) => {
/** Server-side id + owning account for a card in state. */
const contactTarget = (id: string): { originalId: string; accountId?: string } => {
const contact = get().contacts.find((c) => c.id === id);
return {
originalId: contact?.originalId || id,
accountId: contact?.isShared ? contact.accountId : undefined,
};
};
/** Server-side id + owning account for an address book in state. */
const bookTarget = (bookId: string): { originalId: string; accountId?: string; book?: AddressBook } => {
const book = get().addressBooks.find((b) => b.id === bookId);
return {
originalId: book?.originalId || bookId,
accountId: book?.isShared ? book.accountId : undefined,
book,
};
};
/** Tag a freshly created card with the account metadata its book carries. */
const tagCreated = (created: ContactCard, book: AddressBook | undefined): ContactCard => {
if (!book?.isShared || !book.accountId) return created;
const accountId = book.accountId;
return {
...created,
id: namespaceId(accountId, created.id),
originalId: created.id,
addressBookIds: Object.fromEntries(
Object.entries(created.addressBookIds || {}).map(([k, v]) => [namespaceId(accountId, k), v]),
),
accountId,
accountName: book.accountName,
isShared: true,
};
};
/** De-namespace `addressBookIds` of a shared card before sending a patch. */
const cleanPatch = (contact: ContactCard | undefined, changes: Partial<ContactCard>): Partial<ContactCard> => {
if (!contact?.isShared || !contact.accountId || !changes.addressBookIds) return changes;
const prefix = `${contact.accountId}:`;
return {
...changes,
addressBookIds: Object.fromEntries(
Object.entries(changes.addressBookIds).map(([k, v]) => [k.startsWith(prefix) ? k.slice(prefix.length) : k, v]),
),
};
};
const loadAddressBooks = async (): Promise<void> => {
const { stale } = generationGuard();
try {
const addressBooks = (await fetchAllAddressBooks()) ?? [];
if (stale()) return;
set({ addressBooks });
} catch (err) {
if (stale()) return;
set({ error: err instanceof Error ? err.message : 'Failed to load address books' });
}
};
return {
addressBooks: [],
contacts: [],
selectedCategory: { type: 'all' },
loading: false,
error: null,
hydrated: false,
contactsFetchedAt: 0,
trustedSendersBookId: null,
trustedSenderEmails: [],
trustedSendersLoaded: false,
trustedSendersLoading: false,
recentRecipients: [],
recentRecipientsLoaded: false,
sentMailboxId: null,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(SELECTED_CATEGORY_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as ContactCategory;
if (parsed && typeof parsed === 'object' && 'type' in parsed) {
set({ selectedCategory: parsed });
}
}
} catch (err) {
console.warn('[contacts-store] hydrate failed', err);
}
set({ hydrated: true });
},
fetchAddressBooks: async () => {
// No-op until a JMAP session exists. ContactsScreen fires this from a
// mount-time useEffect, which on cold start runs before restoreSession
// has set up the client.
if (!jmapClient.isConnected) return;
if (addressBooksPromise) return addressBooksPromise;
const promise = loadAddressBooks().finally(() => {
// A reset may have handed the slot to the next account's load.
if (addressBooksPromise === promise) addressBooksPromise = null;
});
addressBooksPromise = promise;
return promise;
},
fetchContacts: async (filter) => {
if (!jmapClient.isConnected) return;
if (contactsPromise) return contactsPromise;
const { stale } = generationGuard();
const promise: Promise<void> = (async () => {
// Books first: AddressBook/get is the call that makes Stalwart mint
// the default book, so ContactCard/query must never run alongside it.
if (addressBooksPromise) await addressBooksPromise;
if (stale()) return;
set({ loading: true, error: null });
try {
let contacts: ContactCard[];
const filtered = !!filter && Object.keys(filter).length > 0;
if (filtered) {
const ids = (await queryContacts(filter)) ?? [];
contacts = ids.length > 0 ? ((await fetchContactsByIds(ids)) ?? []) : [];
} else {
contacts = (await fetchAllContacts()) ?? [];
}
if (stale()) return;
// A filtered load leaves only some of the cards in state.
set({ contacts, loading: false, contactsFetchedAt: filtered ? 0 : Date.now() });
} catch (err) {
if (stale()) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' });
}
})().finally(() => {
if (contactsPromise === promise) contactsPromise = null;
});
contactsPromise = promise;
return promise;
},
fetchContactsIfStale: async () => {
const fetchedAt = get().contactsFetchedAt;
if (fetchedAt > 0 && Date.now() - fetchedAt < CONTACTS_STALE_MS) return;
await get().fetchContacts();
},
refresh: async () => {
await get().fetchContacts();
},
handleStateChange: async (change) => {
if (!jmapClient.isConnected) return;
let addressBookChanged = false;
let contactChanged = false;
for (const accountId of getContactCapableAccountIds()) {
const accountChanges = change.changed?.[accountId];
if (!accountChanges) continue;
if ('AddressBook' in accountChanges) addressBookChanged = true;
if ('ContactCard' in accountChanges || 'Contact' in accountChanges) contactChanged = true;
}
if (!addressBookChanged && !contactChanged) return;
if (addressBookChanged) {
await get().fetchAddressBooks();
}
if (contactChanged) {
await get().refresh();
}
},
createContact: async (contact, addressBookId, options) => {
const { check } = generationGuard();
const { originalId, accountId, book } = bookTarget(addressBookId);
// The photo goes up first; the card must not follow it into the next account.
const write = (card: Partial<ContactCard>) => { check(); return apiCreateContact(card, originalId, accountId); };
const saved = options?.photoKey
? await writeContactWithNewPhoto(accountId, contact, options.photoKey, write)
: await write(contact);
const created = tagCreated(saved, book);
check();
set({ contacts: [...get().contacts, created] });
requestDeviceSync(CONTACTS_AUTHORITY);
return created;
},
updateContact: async (id, changes, options) => {
const contact = get().contacts.find((c) => c.id === id);
const { originalId, accountId } = contactTarget(id);
const patch = cleanPatch(contact, changes);
const { check } = generationGuard();
const write = (card: Partial<ContactCard>) => { check(); return apiUpdateContact(originalId, card, accountId); };
if (options?.photoKey) {
await writeContactWithNewPhoto(accountId, patch, options.photoKey, write);
} else {
await write(patch);
}
check();
set({
contacts: get().contacts.map((c) => {
if (c.id !== id) return c;
const merged: ContactCard = { ...c, ...changes };
// A `null` patch value clears the property (JMAP semantics);
// mirror that locally so the UI doesn't resurrect the old value.
for (const [key, value] of Object.entries(changes)) {
if (value === null) delete (merged as unknown as Record<string, unknown>)[key];
}
return merged;
}),
});
requestDeviceSync(CONTACTS_AUTHORITY);
},
fetchContactMedia: async (id) => {
const { originalId, accountId } = contactTarget(id);
try {
const media = await apiGetContactMedia(originalId, accountId);
return media === undefined ? null : { media: media ?? undefined };
} catch (err) {
console.warn('[contacts-store] reading the card media failed', err);
return null;
}
},
deleteContact: async (id) => {
const { check } = generationGuard();
const { originalId, accountId } = contactTarget(id);
await apiDeleteContacts([originalId], accountId);
check();
set({ contacts: cleanGroupMembers(get().contacts, new Set([id])).filter((c) => c.id !== id) });
requestDeviceSync(CONTACTS_AUTHORITY);
},
bulkDelete: async (ids) => {
if (ids.length === 0) return;
// Group by owning account; each account gets one ContactCard/set.
const byAccount = new Map<string | undefined, Array<{ id: string; originalId: string }>>();
for (const id of ids) {
const { originalId, accountId } = contactTarget(id);
const list = byAccount.get(accountId) ?? [];
list.push({ id, originalId });
byAccount.set(accountId, list);
}
const removed = new Set<string>();
let failed = 0;
const { check } = generationGuard();
for (const [accountId, entries] of byAccount) {
check();
try {
const destroyed = new Set(
await apiDeleteContacts(entries.map((e) => e.originalId), accountId),
);
for (const e of entries) {
if (destroyed.has(e.originalId)) removed.add(e.id);
else failed++;
}
} catch (err) {
console.warn('[contacts-store] bulk delete failed', err);
failed += entries.length;
}
}
check();
set({
contacts: cleanGroupMembers(get().contacts, removed).filter((c) => !removed.has(c.id)),
...(failed > 0 ? { error: `Failed to delete ${failed} contact${failed === 1 ? '' : 's'}` } : {}),
});
if (removed.size > 0) requestDeviceSync(CONTACTS_AUTHORITY);
},
importContacts: async (incoming, addressBookId) => {
let imported = 0;
let updated = 0;
let refetch = false;
const failures: ContactImportFailure[] = [];
const { originalId, accountId, book } = bookTarget(addressBookId);
const { check } = generationGuard();
// Stalwart keeps one card per UID in an account and refuses a second
// ("Contact with UID ... already exists"), so re-importing an export
// failed card by card. A card the account already has is updated.
const byUid = new Map<string, ContactCard>();
for (const c of get().contacts) {
// As the server compares them: "x" and "urn:uuid:x" are two UIDs.
if (c.uid && (c.isShared ? c.accountId : undefined) === accountId) byUid.set(c.uid, c);
}
const updateExisting = async (serverId: string, data: Partial<ContactCard>) => {
// The card stays in its own book and keeps its UID.
const { uid: _uid, ...patch } = data;
await apiUpdateContact(serverId, patch, accountId);
check();
const storeId = accountId ? namespaceId(accountId, serverId) : serverId;
if (!get().contacts.some((c) => c.id === storeId)) {
refetch = true;
return;
}
set({ contacts: get().contacts.map((c) => (c.id === storeId ? { ...c, ...patch } : c)) });
};
for (const contact of incoming) {
check();
// Strip the temporary client-side id; the server assigns a real one.
const { id: _id, addressBookIds: _abIds, ...data } = contact;
const known = data.uid ? byUid.get(data.uid) : undefined;
try {
if (known) {
await updateExisting(known.originalId || known.id, data);
updated++;
continue;
}
let created: ContactCard;
try {
created = tagCreated(await apiCreateContact(data, originalId, accountId), book);
} catch (err) {
// A card with this UID the list does not hold (not loaded yet,
// or in another book): the server names it.
const existingId = existingUidCardId(err);
if (!existingId) throw err;
await updateExisting(existingId, data);
updated++;
continue;
}
check();
set({ contacts: [...get().contacts, created] });
imported++;
} catch (err) {
if (err instanceof ContactsAccountChangedError) throw err;
console.warn('[contacts-store] import failed for one contact', err);
failures.push({
name: getContactDisplayName(contact as ContactCard) || getContactPrimaryEmail(contact as ContactCard),
reason: err instanceof Error ? err.message : String(err),
});
}
}
if (imported > 0 || updated > 0) requestDeviceSync(CONTACTS_AUTHORITY);
if (refetch) void get().fetchContacts();
return { imported, updated, failed: failures.length, failures };
},
addContactKeyword: async (id, keyword) => {
const kw = keyword.trim();
if (!kw) return;
const contact = get().contacts.find((c) => c.id === id);
if (!contact) return;
const keywords = { ...(contact.keywords || {}), [kw]: true };
await get().updateContact(id, { keywords });
},
removeContactKeyword: async (id, keyword) => {
const contact = get().contacts.find((c) => c.id === id);
if (!contact?.keywords) return;
const { [keyword]: _removed, ...rest } = contact.keywords;
await get().updateContact(id, { keywords: rest });
},
addKeywordToContacts: async (ids, keyword) => {
const kw = keyword.trim();
if (!kw) return;
for (const id of ids) {
await get().addContactKeyword(id, kw);
}
},
renameKeyword: async (oldKeyword, newKeyword) => {
const oldKw = oldKeyword.trim();
const newKw = newKeyword.trim();
if (!oldKw || !newKw || oldKw === newKw) return;
const affected = get().contacts.filter((c) => c.keywords?.[oldKw]);
for (const contact of affected) {
const { [oldKw]: _old, ...rest } = contact.keywords || {};
await get().updateContact(contact.id, { keywords: { ...rest, [newKw]: true } });
}
const category = get().selectedCategory;
if (category.type === 'keyword' && category.keyword === oldKw) {
get().setSelectedCategory({ type: 'keyword', keyword: newKw });
}
},
moveContactsToAddressBook: async (ids, addressBookId) => {
const target = bookTarget(addressBookId);
const primaryAccountId = getContactsAccountId();
const targetAccount = target.accountId || primaryAccountId;
const { check } = generationGuard();
for (const id of ids) {
check();
const contact = get().contacts.find((c) => c.id === id);
if (!contact) continue;
const source = contactTarget(id);
const sourceAccount = source.accountId || primaryAccountId;
if (sourceAccount === targetAccount) {
await apiUpdateContact(source.originalId, { addressBookIds: { [target.originalId]: true } }, source.accountId);
check();
set({
contacts: get().contacts.map((c) =>
c.id === id ? { ...c, addressBookIds: { [addressBookId]: true } } : c,
),
});
} else {
// Cross-account: create in the target account, then delete the source.
const { id: _id, addressBookIds: _ab, uid: _uid, created: _c, updated: _u, ...data } = stripClientFields(contact);
// A blobId names a photo in the source account and need not
// resolve for the target one; send the bytes. If a download
// fails the entry goes as it is: the create then either resolves
// it or fails, and the source card is only deleted after a
// successful create.
const media = await inlineContactMedia(contact, 'keep');
if (media) data.media = media;
check();
const created = tagCreated(await apiCreateContact(data, target.originalId, target.accountId), target.book);
check();
await apiDeleteContacts([source.originalId], source.accountId);
set({ contacts: get().contacts.map((c) => (c.id === id ? created : c)) });
}
}
requestDeviceSync(CONTACTS_AUTHORITY);
},
addContactsToGroup: async (groupId, contactIds) => {
const group = get().contacts.find((c) => c.id === groupId);
if (!group) throw new Error('Group not found');
const members = { ...(group.members || {}) };
for (const id of contactIds) {
const contact = get().contacts.find((c) => c.id === id);
if (!contact || isGroup(contact)) continue;
// Members are referenced by UID (RFC 9553 §2.1.6); fall back to the
// id for legacy cards that never got one (#644).
members[contact.uid || contact.originalId || contact.id] = true;
}
await get().updateContact(groupId, { members });
},
createGroup: async (name, memberIds, addressBookId) => {
const trimmed = name.trim();
if (!trimmed) throw new Error('Group name is required');
const bookId = addressBookId || get().getDefaultAddressBookId();
if (!bookId) throw new Error('No address book available');
const members: Record<string, boolean> = {};
for (const id of memberIds) {
const contact = get().contacts.find((c) => c.id === id);
if (!contact || isGroup(contact)) continue;
members[contact.uid || contact.originalId || contact.id] = true;
}
return get().createContact(
{
kind: 'group',
name: { components: [{ kind: 'given', value: trimmed }], isOrdered: true, full: trimmed },
members,
},
bookId,
);
},
getGroupRecipients: (groupId) => {
const members = selectGroupMembers(get(), groupId);
const seen = new Set<string>();
const out: EmailAddress[] = [];
for (const m of members) {
if (isGroup(m)) continue;
const email = getContactPrimaryEmail(m).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
const name = sanitizeDisplayName(getContactDisplayName(m));
out.push({ name: name && name !== email ? name : '', email });
}
return out;
},
createAddressBook: async (name) => {
const trimmed = name.trim();
if (!trimmed) throw new Error('Address book name is required');
const { check } = generationGuard();
const book = await apiCreateAddressBook(trimmed);
check();
set({ addressBooks: [...get().addressBooks, book] });
requestDeviceSync(CONTACTS_AUTHORITY);
return book;
},
renameAddressBook: async (id, name) => {
const trimmed = name.trim();
if (!trimmed) return;
const { check } = generationGuard();
const { originalId, accountId } = bookTarget(id);
await apiUpdateAddressBook(originalId, { name: trimmed }, accountId);
check();
set({
addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)),
});
requestDeviceSync(CONTACTS_AUTHORITY);
},
deleteAddressBook: async (id) => {
const { check } = generationGuard();
const { originalId, accountId } = bookTarget(id);
// The confirmation names the cards that go with it.
await apiDeleteAddressBook(originalId, accountId, { removeContents: true });
check();
// The server destroys the cards filed in the book along with it, so
// drop them from the cache instead of leaving orphans under "All".
set({
addressBooks: get().addressBooks.filter((b) => b.id !== id),
contacts: get().contacts.filter((c) => !c.addressBookIds?.[id]),
});
requestDeviceSync(CONTACTS_AUTHORITY);
const category = get().selectedCategory;
if (category.type === 'addressBook' && category.addressBookId === id) {
get().setSelectedCategory({ type: 'all' });
}
},
setDefaultAddressBook: async (id) => {
const { check } = generationGuard();
const { originalId, accountId, book } = bookTarget(id);
await apiSetDefaultAddressBook(originalId, accountId);
check();
set({
addressBooks: get().addressBooks.map((b) => {
if (b.id === id) return { ...b, isDefault: true };
// Only one default per account - clear the flag on siblings.
if (b.isDefault && (b.accountId ?? null) === (book?.accountId ?? null)) {
return { ...b, isDefault: false };
}
return b;
}),
});
},
getDefaultAddressBookId: () => {
const books = get().addressBooks;
const own = books.filter((b) => !b.isShared && b.myRights?.mayWrite !== false);
const candidates = own.length > 0 ? own : books.filter((b) => b.myRights?.mayWrite !== false);
return (candidates.find((b) => b.isDefault) || candidates[0])?.id ?? null;
},
loadRecentRecipients: async (sentMailboxId) => {
if (sentMailboxId) set({ sentMailboxId });
const mailboxId = get().sentMailboxId;
if (get().recentRecipientsLoaded || !mailboxId || !jmapClient.isConnected) return;
const { stale } = generationGuard();
try {
const recentRecipients = await queryRecentRecipients(mailboxId, 300);
if (stale()) return;
set({ recentRecipients, recentRecipientsLoaded: true });
} catch (err) {
console.warn('[contacts-store] load recent recipients failed', err);
if (!stale()) set({ recentRecipientsLoaded: true });
}
},
searchRecipients: async (query) => {
const mailboxId = get().sentMailboxId;
const q = query.trim();
if (!mailboxId || !q || !jmapClient.isConnected) return [];
try {
return normalizeSuggestions(await searchSentRecipients(q, mailboxId));
} catch (err) {
console.warn('[contacts-store] recipient search failed', err);
return [];
}
},
getAutocomplete: (query, limit = 10) => {
const q = query.trim().toLowerCase();
if (!q) return [];
const { contacts, recentRecipients } = get();
const results: RecipientSuggestion[] = [];
for (const contact of contacts) {
if (results.length >= limit) break;
if (isGroup(contact)) {
// Suggest the group itself as a single entry (Outlook-style);
// picking it expands to the member addresses.
const groupName = getContactDisplayName(contact);
if (groupName && groupName.toLowerCase().includes(q)) {
const memberCount = get().getGroupRecipients(contact.id).length;
if (memberCount > 0) {
results.push({ name: groupName, email: '', group: { id: contact.id, memberCount } });
}
}
continue;
}
const name = getContactDisplayName(contact);
const emails = contact.emails ? Object.values(contact.emails) : [];
for (const entry of emails) {
if (!entry.address) continue;
if (name.toLowerCase().includes(q) || entry.address.toLowerCase().includes(q)) {
results.push({ name, email: entry.address });
}
}
}
// Fold in recent recipients from the Sent folder. Contacts take
// precedence, so skip any address already suggested.
if (recentRecipients.length > 0 && results.length < limit) {
const seen = new Set(results.map((r) => r.email.toLowerCase()));
for (const rec of recentRecipients) {
if (results.length >= limit) break;
const addr = rec.email.toLowerCase();
if (seen.has(addr)) continue;
if (addr.includes(q) || rec.name.toLowerCase().includes(q)) {
results.push({ name: rec.name, email: rec.email });
seen.add(addr);
}
}
}
return normalizeSuggestions(results);
},
findContactByEmail: (email) => {
const needle = splitMailbox(email).email.toLowerCase().trim();
if (!needle) return undefined;
return get().contacts.find((c) =>
!isGroup(c) && !!c.emails
&& Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === needle));
},
loadTrustedSendersBook: async (createIfMissing = false) => {
if (!jmapClient.isConnected) return;
// Share the in-flight load so parallel callers (mail view, settings,
// addToTrustedSendersBook) never create the book twice - and so a
// caller that needs the book id can wait for it instead of failing.
if (trustedSendersPromise) await trustedSendersPromise;
if (get().trustedSendersBookId) return;
if (get().trustedSendersLoaded && !createIfMissing) return;
set({ trustedSendersLoading: true });
const { stale } = generationGuard();
const promise: Promise<void> = (async () => {
try {
// Must throw rather than yield [] on failure: treating a failed
// fetch as "no book yet" minted a duplicate on every hiccup (#730).
const books = (await fetchPrimaryAddressBooks()) ?? [];
// Sort so every client picks the same book when duplicates exist.
const matches = books
.filter((b) => b.name === TRUSTED_SENDERS_BOOK_NAME)
.sort((a, b) => a.id.localeCompare(b.id));
let book = matches[0];
if (stale()) return;
if (!book) {
if (!createIfMissing) {
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
return;
}
book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME);
if (stale()) return;
}
const bookId = book.id;
const cards = await getContactsInBook(bookId);
if (stale()) return;
set({
trustedSendersBookId: bookId,
trustedSenderEmails: collectContactEmails(cards),
trustedSendersLoaded: true,
trustedSendersLoading: false,
});
} catch (err) {
console.warn('[contacts-store] load trusted senders failed', err);
if (!stale()) set({ trustedSendersLoaded: true, trustedSendersLoading: false });
}
})().finally(() => {
if (trustedSendersPromise === promise) trustedSendersPromise = null;
});
trustedSendersPromise = promise;
return promise;
},
addToTrustedSendersBook: async (input) => {
const { name, email } = parseTrustedSenderInput(input);
if (!email || get().trustedSenderEmails.includes(email)) return;
let bookId = get().trustedSendersBookId;
if (!bookId) {
await get().loadTrustedSendersBook(true);
bookId = get().trustedSendersBookId;
}
if (!bookId) throw new Error('Could not find or create the trusted senders address book');
// The load may have found the address already filed in the book.
if (get().trustedSenderEmails.includes(email)) return;
const { check } = generationGuard();
await apiCreateContact(
{
...(name ? { name: { full: name } } : {}),
emails: { email: { address: email } },
},
bookId,
);
check();
set({ trustedSenderEmails: [...get().trustedSenderEmails, email] });
requestDeviceSync(CONTACTS_AUTHORITY);
},
removeFromTrustedSendersBook: async (input) => {
const { email } = parseTrustedSenderInput(input);
const bookId = get().trustedSendersBookId;
if (!bookId || !email) return;
const { check } = generationGuard();
const cards = await getContactsInBook(bookId);
check();
const matches = cards.filter((c) =>
c.emails && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === email));
if (matches.length > 0) await apiDeleteContacts(matches.map((m) => m.id));
check();
set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== email) });
if (matches.length > 0) requestDeviceSync(CONTACTS_AUTHORITY);
},
isTrustedAddressBookSender: (email) =>
get().trustedSenderEmails.includes(email.toLowerCase().trim()),
setSelectedCategory: (category) => {
set({ selectedCategory: category });
persistCategory(category);
},
reset: () => {
storeGeneration += 1;
// The next account's first load must not join one still under way
// for this one.
addressBooksPromise = null;
contactsPromise = null;
trustedSendersPromise = null;
set({
addressBooks: [],
contacts: [],
selectedCategory: { type: 'all' },
loading: false,
error: null,
contactsFetchedAt: 0,
trustedSendersBookId: null,
trustedSenderEmails: [],
trustedSendersLoaded: false,
trustedSendersLoading: false,
recentRecipients: [],
recentRecipientsLoaded: false,
sentMailboxId: null,
});
},
};
},
{
// Persist address books and contact cards so the list renders instantly
// on re-open. A refresh runs in the background once the JMAP session is
// ready and replaces the cached data.
name: 'contacts-cache',
storage: createPersistStorage(),
partialize: memoizeSlice(
(state: ContactsState) => [state.addressBooks, state.contacts],
(state) => ({
addressBooks: state.addressBooks,
// Photos are inline base64 blobs; a few hundred of them blow past
// Android AsyncStorage's write cap and the cache silently stops
// updating. They are re-hydrated from the server on refresh.
contacts: state.contacts.map(({ media: _media, ...rest }) => rest),
}),
),
},
),
);
// ─── Selectors ───────────────────────────────────────────
export function selectIndividuals(state: ContactsState): ContactCard[] {
return state.contacts.filter((c) => !isGroup(c));
}
export function selectGroups(state: ContactsState): ContactCard[] {
return state.contacts.filter(isGroup);
}
export function selectKeywordsUsed(contacts: ContactCard[]): Array<{ keyword: string; count: number }> {
const counts = new Map<string, number>();
for (const contact of contacts) {
for (const kw of getContactKeywords(contact)) {
counts.set(kw, (counts.get(kw) || 0) + 1);
}
}
return Array.from(counts.entries())
.map(([keyword, count]) => ({ keyword, count }))
.sort((a, b) => a.keyword.localeCompare(b.keyword));
}
/** Contacts without any category (keyword) - the webmail's "No category". */
export function selectUncategorized(contacts: ContactCard[]): ContactCard[] {
return contacts.filter((c) => !isGroup(c) && getContactKeywords(c).length === 0);
}
export function selectAddressBooksWithCount(
addressBooks: AddressBook[],
contacts: ContactCard[],
): Array<AddressBook & { count: number }> {
return addressBooks.map((book) => ({
...book,
count: contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
}));
}
/**
* The address book a contact created from the list should go into: the one
* being viewed (webmail 494adb9b), unless it is read-only. `undefined` lets
* the form fall back to the default book.
*/
export function selectCreateTargetBookId(
category: ContactCategory,
addressBooks: AddressBook[],
): string | undefined {
if (category.type !== 'addressBook') return undefined;
const book = addressBooks.find((b) => b.id === category.addressBookId);
return book && book.myRights?.mayWrite !== false ? book.id : undefined;
}
export function selectGroupMembers(state: Pick<ContactsState, 'contacts'>, groupId: string): ContactCard[] {
const group = state.contacts.find((c) => c.id === groupId);
if (!group?.members) return [];
const memberKeys = Object.keys(group.members).filter((k) => group.members![k]);
const normalized = memberKeys.map((k) => (k.startsWith('urn:uuid:') ? k.slice(9) : k));
const keySet = new Set([...memberKeys, ...normalized]);
return state.contacts.filter((c) => {
if (keySet.has(c.id)) return true;
if (c.originalId && keySet.has(c.originalId)) return true;
if (c.uid) {
const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
if (keySet.has(c.uid) || keySet.has(bareUid)) return true;
}
return false;
});
}
export function selectVisibleContacts(
state: ContactsState,
query: string,
): ContactCard[] {
const individuals = selectIndividuals(state);
const category = state.selectedCategory;
let filtered: ContactCard[];
switch (category.type) {
case 'all':
filtered = individuals;
break;
case 'addressBook':
filtered = individuals.filter((c) => c.addressBookIds?.[category.addressBookId]);
break;
case 'group': {
filtered = selectGroupMembers(state, category.groupId).filter((c) => !isGroup(c));
break;
}
case 'keyword':
filtered = individuals.filter((c) => c.keywords?.[category.keyword]);
break;
case 'uncategorized':
filtered = selectUncategorized(individuals);
break;
default:
filtered = individuals;
}
if (!query) return filtered;
return filtered.filter((c) => matchesContactSearch(c, query));
}
export function sortContactsByDisplayName(contacts: ContactCard[]): ContactCard[] {
return sortContactsByName(contacts, false);
}
// One collator for every contact sort: `localeCompare` with options sets one
// up per comparison, which made a large address book slow to sort (PF10).
const contactNameCollator = new Intl.Collator(undefined, { sensitivity: 'base' });
/** Contact list order: by display name, or surname first with `byLastName` (#963). */
export function sortContactsByName(contacts: ContactCard[], byLastName: boolean): ContactCard[] {
// Work out each sort name once instead of twice per comparison.
const keyed = contacts.map((contact) => ({ contact, key: getContactSortName(contact, byLastName) }));
keyed.sort((a, b) => contactNameCollator.compare(a.key, b.key));
return keyed.map((entry) => entry.contact);
}