feat: browse the company directory in Contacts #1253

This commit is contained in:
Linus Rath
2026-10-29 03:34:44 +01:00
parent 07fd03e284
commit 0fd14a837a
7 changed files with 425 additions and 7 deletions
+4
View File
@@ -194,6 +194,10 @@
"timezone_value": "Timezone: {zone}", "timezone_value": "Timezone: {zone}",
"updated_on": "Updated {date}" "updated_on": "Updated {date}"
}, },
"directory": {
"restricted_hint": "Your mail server lists only the accounts you already work with, such as your groups and the people who share with you.",
"restricted_queries_off": "Your mail server lists only the accounts you already work with, such as your groups and the people who share with you. An administrator can turn on directory queries to show everyone."
},
"empty_state_hint_mobile": "Tap + to add one", "empty_state_hint_mobile": "Tap + to add one",
"export": { "export": {
"failed": "Export failed" "failed": "Export failed"
@@ -26,6 +26,7 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'components/contacts/TagAssignSheet.tsx': [], 'components/contacts/TagAssignSheet.tsx': [],
'components/contacts/ContactListRow.tsx': [], 'components/contacts/ContactListRow.tsx': [],
'components/contacts/FieldBlock.tsx': [], 'components/contacts/FieldBlock.tsx': [],
'components/contacts/DirectoryBrowser.tsx': [],
}; };
describe('translated contacts screens', () => { describe('translated contacts screens', () => {
@@ -0,0 +1,189 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, FlatList, ActivityIndicator, RefreshControl } from 'react-native';
import { AlertCircle, BookUser, Lock, Users, MapPin } from '../icons';
import SenderAvatar from '../SenderAvatar';
import { AddressActionSheet } from '../email/AddressActionSheet';
import type { EmailAddress } from '../../api/types';
import { filterDirectory, type DirectoryEntry } from '../../lib/contact-directory';
import { useContactsStore } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface DirectoryBrowserProps {
/** The Contacts search, applied to the directory while it shows. */
query: string;
}
/**
* The company directory (#1253, webmail components/contacts/directory-browser
* .tsx): the people and groups the server lists for this account, read-only.
* A tap offers to write to the entry or add it to the contacts.
*/
export function DirectoryBrowser({ query }: DirectoryBrowserProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const entries = useContactsStore((s) => s.directory);
const status = useContactsStore((s) => s.directoryStatus);
const queriesOff = useContactsStore((s) => s.directoryQueriesOff);
const fetchDirectory = useContactsStore((s) => s.fetchDirectory);
const [sheet, setSheet] = React.useState<EmailAddress | null>(null);
React.useEffect(() => {
if (status === 'idle') void fetchDirectory();
}, [status, fetchDirectory]);
const filtered = React.useMemo(() => filterDirectory(entries, query, locale), [entries, query, locale]);
const loading = status === 'loading' || status === 'idle';
const typeLabel = (entry: DirectoryEntry): string | null => {
if (!entry.type || entry.type === 'individual') return null;
return t(`contacts.directory.type.${entry.type}`, entry.type);
};
let placeholder: React.ReactNode = null;
if (entries.length === 0) {
if (loading) {
placeholder = <ActivityIndicator color={c.primary} />;
} else if (status === 'unavailable') {
placeholder = (
<StatePanel styles={styles} icon={<BookUser size={40} color={c.surfaceActive} />}
title={t('contacts.directory.unavailable_title', 'No directory')}
hint={t('contacts.directory.unavailable_hint', "Your mail server doesn't offer a directory of accounts.")} />
);
} else if (status === 'restricted') {
placeholder = (
<StatePanel styles={styles} icon={<Lock size={40} color={c.surfaceActive} />}
title={t('contacts.directory.forbidden_title', 'Directory not available')}
hint={t('contacts.directory.forbidden_hint', "Your mail server doesn't allow this account to browse the directory. Ask your administrator.")} />
);
} else if (status === 'error') {
placeholder = (
<StatePanel styles={styles} icon={<AlertCircle size={40} color={c.surfaceActive} />}
title={t('contacts.directory.error_title', "Couldn't load the directory")}
hint={t('contacts.directory.error_hint', 'Check your connection and try again.')} />
);
} else {
placeholder = (
<StatePanel styles={styles} icon={<BookUser size={40} color={c.surfaceActive} />}
title={t('contacts.directory.empty_title', 'The directory is empty')}
hint={t('contacts.directory.empty_hint', 'Your mail server lists no accounts with an email address.')} />
);
}
} else if (filtered.length === 0) {
placeholder = (
<StatePanel styles={styles} icon={<BookUser size={40} color={c.surfaceActive} />}
title={t('contacts.directory.no_match', 'No one in the directory matches your search')} />
);
}
// Stalwart 1.0 keeps directory queries off unless the administrator turns
// them on, and then lists only the principals the user already knows.
const restrictedNote = status === 'restricted' && entries.length > 0
? queriesOff
? t('contacts.directory.restricted_queries_off', 'Your mail server lists only the accounts you already work with, such as your groups and the people who share with you. An administrator can turn on directory queries to show everyone.')
: t('contacts.directory.restricted_hint', 'Your mail server lists only the accounts you already work with, such as your groups and the people who share with you.')
: null;
return (
<View style={{ flex: 1 }}>
<FlatList
data={filtered}
keyExtractor={(e) => e.email.toLowerCase()}
ListHeaderComponent={restrictedNote ? (
<View style={styles.note}>
<Lock size={14} color={c.mutedForeground} />
<Text style={styles.noteText}>{restrictedNote}</Text>
</View>
) : null}
renderItem={({ item }) => {
const kind = typeLabel(item);
return (
<Pressable
onPress={() => setSheet({ name: item.name !== item.email ? item.name : undefined, email: item.email })}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
>
{item.type === 'group' || item.type === 'location' ? (
<View style={styles.iconAvatar}>
{item.type === 'group'
? <Users size={18} color={c.primary} />
: <MapPin size={18} color={c.primary} />}
</View>
) : (
<SenderAvatar name={item.name} email={item.email} size={componentSizes.avatarMd} photo={null} />
)}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{kind ? `${item.email} · ${kind}` : item.email}
</Text>
</View>
</Pressable>
);
}}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={<View style={styles.empty}>{placeholder}</View>}
refreshControl={
<RefreshControl refreshing={status === 'loading' && entries.length > 0} onRefresh={() => void fetchDirectory()} tintColor={c.primary} />
}
contentContainerStyle={{ flexGrow: 1 }}
/>
<AddressActionSheet address={sheet} onClose={() => setSheet(null)} />
</View>
);
}
function StatePanel({ styles, icon, title, hint }: {
styles: ReturnType<typeof makeStyles>;
icon: React.ReactNode;
title: string;
hint?: string;
}) {
return (
<>
{icon}
<Text style={styles.emptyTitle}>{title}</Text>
{!!hint && <Text style={styles.emptyHint}>{hint}</Text>}
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
gap: spacing.md,
},
rowPressed: { backgroundColor: c.surfaceHover },
iconAvatar: {
width: componentSizes.avatarMd,
height: componentSizes.avatarMd,
borderRadius: componentSizes.avatarMd / 2,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textSecondary, marginTop: 1 },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border, marginLeft: spacing.lg },
note: {
flexDirection: 'row',
gap: spacing.sm,
margin: spacing.lg,
padding: spacing.md,
borderRadius: radius.md,
backgroundColor: c.muted,
},
noteText: { ...typography.caption, color: c.mutedForeground, flex: 1 },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl, gap: spacing.md },
emptyTitle: { ...typography.bodyMedium, color: c.text, textAlign: 'center' },
emptyHint: { ...typography.caption, color: c.mutedForeground, textAlign: 'center' },
});
}
+80
View File
@@ -0,0 +1,80 @@
import type { Principal } from '../api/types';
// The company directory (#1253): the RFC 9670 principals the server lists for
// this account, read-only, for the Contacts "Directory" view and recipient
// lookups. Ported from the webmail's stores/contact-store.ts and
// components/contacts/directory-browser.tsx.
export interface DirectoryEntry {
id?: string;
/** Display name: the principal's description, else its name, else the address. */
name: string;
email: string;
description?: string;
type?: Principal['type'];
}
/**
* Where the directory load stands:
* - `unavailable`: the server has no principals capability;
* - `restricted`: the server refused to list its whole directory (Stalwart
* 1.0 keeps directory queries off by default, or the account lacks the
* right); the entries are the principals the user already knows: their
* own account, their groups, the accounts that shared something with them;
* - `error`: the request failed.
*/
export type DirectoryStatus = 'idle' | 'loading' | 'ready' | 'restricted' | 'unavailable' | 'error';
export function principalsToDirectory(principals: Principal[]): DirectoryEntry[] {
const entries: DirectoryEntry[] = [];
const seen = new Set<string>();
for (const p of principals) {
// Only principals with an address can be written to.
const email = p.email?.trim();
if (!email || seen.has(email.toLowerCase())) continue;
seen.add(email.toLowerCase());
entries.push({
id: p.id,
name: p.description || p.name || email,
email,
description: p.description ?? undefined,
type: p.type,
});
}
return entries;
}
function matches(entry: DirectoryEntry, q: string): boolean {
return entry.name.toLowerCase().includes(q)
|| entry.email.toLowerCase().includes(q)
|| (entry.description?.toLowerCase().includes(q) ?? false);
}
/** The entries a search leaves, by name then address. */
export function filterDirectory(entries: DirectoryEntry[], query: string, locale?: string): DirectoryEntry[] {
const q = query.trim().toLowerCase();
const matched = q ? entries.filter((e) => matches(e, q)) : entries;
const collator = new Intl.Collator(locale, { sensitivity: 'base' });
return [...matched].sort((a, b) => collator.compare(a.name, b.name) || collator.compare(a.email, b.email));
}
/**
* Recipient suggestions from the directory for what is typed, as the
* composer's autocomplete shows them: a name only where it says more than the
* address.
*/
export function directorySuggestions(
entries: DirectoryEntry[],
query: string,
limit = 10,
): Array<{ name: string; email: string }> {
const q = query.trim().toLowerCase();
if (!q) return [];
const out: Array<{ name: string; email: string }> = [];
for (const entry of entries) {
if (out.length >= limit) break;
if (!matches(entry, q)) continue;
out.push({ name: entry.name !== entry.email ? entry.name : '', email: entry.email });
}
return out;
}
+31 -7
View File
@@ -43,6 +43,8 @@ import {
} from '../components/contacts'; } from '../components/contacts';
import Dialog from '../components/Dialog'; import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer'; import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { DirectoryBrowser } from '../components/contacts/DirectoryBrowser';
import { supportsSharing } from '../api/files';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -135,6 +137,10 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName); const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
// The company directory shows in place of the list (#1253). Not a stored
// category: it is the server's list, not the user's contacts.
const [showDirectory, setShowDirectory] = React.useState(false);
const directoryAvailable = supportsSharing();
const [searchActive, setSearchActive] = React.useState(false); const [searchActive, setSearchActive] = React.useState(false);
const [drawerOpen, setDrawerOpen] = React.useState(false); const [drawerOpen, setDrawerOpen] = React.useState(false);
const [selection, setSelection] = React.useState<Set<string>>(new Set()); const [selection, setSelection] = React.useState<Set<string>>(new Set());
@@ -353,7 +359,9 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
[addressBooks, importTargetBookId], [addressBooks, importTargetBookId],
); );
const title = categoryTitle(selectedCategory, addressBooks, groups, t); const title = showDirectory
? t('contacts.directory.title', 'Directory')
: categoryTitle(selectedCategory, addressBooks, groups, t);
// Top chip row: All / per-address-book / Groups // Top chip row: All / per-address-book / Groups
const chips = React.useMemo(() => { const chips = React.useMemo(() => {
@@ -546,18 +554,34 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
) : ( ) : (
<Pressable <Pressable
key={chip.key} key={chip.key}
onPress={() => setSelectedCategory(chip.category)} onPress={() => { setShowDirectory(false); setSelectedCategory(chip.category); }}
style={[styles.chip, chip.active && styles.chipActive]} style={[styles.chip, chip.active && !showDirectory && styles.chipActive]}
hitSlop={{ top: 6, bottom: 6 }} hitSlop={{ top: 6, bottom: 6 }}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ selected: chip.active }} accessibilityState={{ selected: chip.active && !showDirectory }}
> >
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}> <Text style={[styles.chipText, chip.active && !showDirectory && styles.chipTextActive]} numberOfLines={1}>
{chip.label} {chip.label}
</Text> </Text>
</Pressable> </Pressable>
), ),
)} )}
{directoryAvailable && (
<>
<View style={styles.chipDivider} />
<Pressable
onPress={() => setShowDirectory(true)}
style={[styles.chip, showDirectory && styles.chipActive]}
hitSlop={{ top: 6, bottom: 6 }}
accessibilityRole="button"
accessibilityState={{ selected: showDirectory }}
>
<Text style={[styles.chipText, showDirectory && styles.chipTextActive]} numberOfLines={1}>
{t('contacts.directory.title', 'Directory')}
</Text>
</Pressable>
</>
)}
</ScrollView> </ScrollView>
)} )}
@@ -567,7 +591,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
</View> </View>
)} )}
<SectionList {showDirectory && !selectionMode ? <DirectoryBrowser query={searchQuery} /> : <SectionList
sections={sections} sections={sections}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item }) => ( renderItem={({ item }) => (
@@ -621,7 +645,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
</View> </View>
) )
} }
/> />}
<Dialog <Dialog
visible={confirmBulkDelete} visible={confirmBulkDelete}
@@ -47,6 +47,11 @@ vi.mock('../../api/jmap-client', () => ({
}, },
})); }));
vi.mock('../../api/files', () => ({
listPrincipals: vi.fn(),
supportsSharing: vi.fn(() => true),
}));
vi.mock('../../api/blob', () => ({ vi.mock('../../api/blob', () => ({
uploadBytes: vi.fn(), uploadBytes: vi.fn(),
getBlobsAsBase64: vi.fn(), getBlobsAsBase64: vi.fn(),
@@ -54,6 +59,7 @@ vi.mock('../../api/blob', () => ({
import * as contactsApi from '../../api/contacts'; import * as contactsApi from '../../api/contacts';
import * as blobApi from '../../api/blob'; import * as blobApi from '../../api/blob';
import * as filesApi from '../../api/files';
import { jmapClient } from '../../api/jmap-client'; import { jmapClient } from '../../api/jmap-client';
import { resetContactBlobMediaRefusals } from '../../lib/contact-media'; import { resetContactBlobMediaRefusals } from '../../lib/contact-media';
import * as recentApi from '../../api/recent-recipients'; import * as recentApi from '../../api/recent-recipients';
@@ -632,6 +638,56 @@ describe('contacts-store', () => {
}); });
}); });
describe('company directory (#1253)', () => {
const mockListPrincipals = filesApi.listPrincipals as ReturnType<typeof vi.fn>;
const principal = (id: string, name: string, email?: string) =>
({ id, type: 'individual' as const, name, email });
it('lists the principals with an address', async () => {
mockListPrincipals.mockResolvedValue({
status: 'ok',
principals: [principal('a', 'anna', 'anna@example.org'), principal('g', 'admins'), principal('b', 'bob', 'bob@example.org')],
});
await useContactsStore.getState().fetchDirectory();
const s = useContactsStore.getState();
expect(s.directoryStatus).toBe('ready');
expect(s.directory.map((e) => e.email)).toEqual(['anna@example.org', 'bob@example.org']);
});
// Stalwart 1.0 answers forbidden while directory queries are off (its
// default) and hands back the principals the user knows.
it('keeps the known principals of a restricted listing and says why', async () => {
mockListPrincipals.mockResolvedValue({
status: 'restricted', directoryQueriesOff: true, principals: [principal('me', 'me', 'me@example.org')],
});
await useContactsStore.getState().fetchDirectory();
const s = useContactsStore.getState();
expect(s.directoryStatus).toBe('restricted');
expect(s.directoryQueriesOff).toBe(true);
expect(s.directory).toHaveLength(1);
});
it('looks up recipients in the loaded directory', async () => {
mockListPrincipals.mockResolvedValue({
status: 'ok', principals: [{ ...principal('a', 'anna', 'anna@example.org'), description: 'Anna Berg' }],
});
await useContactsStore.getState().fetchDirectory();
expect(useContactsStore.getState().lookupDirectory('berg')).toEqual([{ name: 'Anna Berg', email: 'anna@example.org' }]);
expect(useContactsStore.getState().lookupDirectory('zzz')).toEqual([]);
});
it('drops a listing that arrives after an account switch', async () => {
let resolve: (v: unknown) => void = () => {};
mockListPrincipals.mockReturnValue(new Promise((r) => { resolve = r; }));
const load = useContactsStore.getState().fetchDirectory();
useContactsStore.getState().reset();
resolve({ status: 'ok', principals: [principal('a', 'anna', 'anna@example.org')] });
await load;
expect(useContactsStore.getState().directory).toEqual([]);
expect(useContactsStore.getState().directoryStatus).toBe('idle');
});
});
describe('an account switch while contacts load or save', () => { describe('an account switch while contacts load or save', () => {
function deferred<T>() { function deferred<T>() {
let resolve: (v: T) => void = () => {}; let resolve: (v: T) => void = () => {};
+64
View File
@@ -35,6 +35,13 @@ import {
matchesContactSearch, matchesContactSearch,
} from '../lib/contact-utils'; } from '../lib/contact-utils';
import { sanitizeDisplayName, splitMailbox } from '../lib/rfc5322-mailbox'; import { sanitizeDisplayName, splitMailbox } from '../lib/rfc5322-mailbox';
import { listPrincipals, supportsSharing } from '../api/files';
import {
directorySuggestions,
principalsToDirectory,
type DirectoryEntry,
type DirectoryStatus,
} from '../lib/contact-directory';
import { requestDeviceSync } from '../device-sync/app/request'; import { requestDeviceSync } from '../device-sync/app/request';
import { CONTACTS_AUTHORITY } from '../device-sync/types'; import { CONTACTS_AUTHORITY } from '../device-sync/types';
@@ -81,6 +88,15 @@ export interface ContactsState {
recentRecipientsLoaded: boolean; recentRecipientsLoaded: boolean;
sentMailboxId: string | null; sentMailboxId: string | null;
// The company directory (#1253): principals the server lists, read-only.
directory: DirectoryEntry[];
directoryStatus: DirectoryStatus;
/**
* The server refused the listing because its administrator keeps directory
* queries off (Stalwart 1.0's default), not over this account's rights.
*/
directoryQueriesOff: boolean;
hydrate: () => Promise<void>; hydrate: () => Promise<void>;
fetchAddressBooks: () => Promise<void>; fetchAddressBooks: () => Promise<void>;
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>; fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
@@ -134,6 +150,13 @@ export interface ContactsState {
searchRecipients: (query: string) => Promise<RecipientSuggestion[]>; searchRecipients: (query: string) => Promise<RecipientSuggestion[]>;
getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[]; getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[];
findContactByEmail: (email: string) => ContactCard | undefined; findContactByEmail: (email: string) => ContactCard | undefined;
/** Load the company directory (the server's principals). */
fetchDirectory: () => Promise<void>;
/**
* Directory people matching what is typed, for recipient fields. Reads what
* fetchDirectory loaded; empty until then.
*/
lookupDirectory: (query: string, limit?: number) => RecipientSuggestion[];
// Trusted senders address book. Passive loads (createIfMissing=false) only // Trusted senders address book. Passive loads (createIfMissing=false) only
// read an existing book; the book is created lazily on the first add. // read an existing book; the book is created lazily on the first add.
@@ -268,6 +291,7 @@ export function normalizeSuggestions(results: RecipientSuggestion[]): RecipientS
let addressBooksPromise: Promise<void> | null = null; let addressBooksPromise: Promise<void> | null = null;
let contactsPromise: Promise<void> | null = null; let contactsPromise: Promise<void> | null = null;
let trustedSendersPromise: Promise<void> | null = null; let trustedSendersPromise: Promise<void> | null = null;
let directoryPromise: Promise<void> | null = null;
// One up with every reset (an account switch, a sign-out), as in the calendar // 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, // store. The client is one singleton that serves whichever account is active,
@@ -384,6 +408,10 @@ export const useContactsStore = create<ContactsState>()(
recentRecipientsLoaded: false, recentRecipientsLoaded: false,
sentMailboxId: null, sentMailboxId: null,
directory: [],
directoryStatus: 'idle',
directoryQueriesOff: false,
hydrate: async () => { hydrate: async () => {
if (get().hydrated) return; if (get().hydrated) return;
try { try {
@@ -921,6 +949,38 @@ export const useContactsStore = create<ContactsState>()(
&& Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === needle)); && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === needle));
}, },
fetchDirectory: async () => {
if (!jmapClient.isConnected) return;
if (!supportsSharing()) {
set({ directoryStatus: 'unavailable' });
return;
}
if (directoryPromise) return directoryPromise;
const { stale } = generationGuard();
set({ directoryStatus: 'loading' });
const promise: Promise<void> = (async () => {
const listing = await listPrincipals();
if (stale()) return;
if (listing.status === 'error' && listing.principals.length === 0) {
// Keep what an earlier load returned.
set({ directoryStatus: 'error' });
return;
}
set({
directory: principalsToDirectory(listing.principals),
directoryStatus: listing.status === 'restricted' ? 'restricted' : listing.status === 'error' ? 'error' : 'ready',
directoryQueriesOff: !!listing.directoryQueriesOff,
});
})().finally(() => {
if (directoryPromise === promise) directoryPromise = null;
});
directoryPromise = promise;
return promise;
},
lookupDirectory: (query, limit = 10) =>
normalizeSuggestions(directorySuggestions(get().directory, query, limit)),
loadTrustedSendersBook: async (createIfMissing = false) => { loadTrustedSendersBook: async (createIfMissing = false) => {
if (!jmapClient.isConnected) return; if (!jmapClient.isConnected) return;
// Share the in-flight load so parallel callers (mail view, settings, // Share the in-flight load so parallel callers (mail view, settings,
@@ -1027,6 +1087,7 @@ export const useContactsStore = create<ContactsState>()(
addressBooksPromise = null; addressBooksPromise = null;
contactsPromise = null; contactsPromise = null;
trustedSendersPromise = null; trustedSendersPromise = null;
directoryPromise = null;
set({ set({
addressBooks: [], addressBooks: [],
contacts: [], contacts: [],
@@ -1041,6 +1102,9 @@ export const useContactsStore = create<ContactsState>()(
recentRecipients: [], recentRecipients: [],
recentRecipientsLoaded: false, recentRecipientsLoaded: false,
sentMailboxId: null, sentMailboxId: null,
directory: [],
directoryStatus: 'idle',
directoryQueriesOff: false,
}); });
}, },
}; };