From 0fd14a837a305b45f1a46b78421824b22123274e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:34:44 +0100 Subject: [PATCH] feat: browse the company directory in Contacts #1253 --- locales/rn/en.json | 4 + .../__tests__/contacts-i18n.test.ts | 1 + src/components/contacts/DirectoryBrowser.tsx | 189 ++++++++++++++++++ src/lib/contact-directory.ts | 80 ++++++++ src/screens/ContactsScreen.tsx | 38 +++- src/stores/__tests__/contacts-store.test.ts | 56 ++++++ src/stores/contacts-store.ts | 64 ++++++ 7 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 src/components/contacts/DirectoryBrowser.tsx create mode 100644 src/lib/contact-directory.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index 98ab9795..911408fb 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -194,6 +194,10 @@ "timezone_value": "Timezone: {zone}", "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", "export": { "failed": "Export failed" diff --git a/src/components/__tests__/contacts-i18n.test.ts b/src/components/__tests__/contacts-i18n.test.ts index 833e27f6..87754386 100644 --- a/src/components/__tests__/contacts-i18n.test.ts +++ b/src/components/__tests__/contacts-i18n.test.ts @@ -26,6 +26,7 @@ const TRANSLATED_FILES: Record = { 'components/contacts/TagAssignSheet.tsx': [], 'components/contacts/ContactListRow.tsx': [], 'components/contacts/FieldBlock.tsx': [], + 'components/contacts/DirectoryBrowser.tsx': [], }; describe('translated contacts screens', () => { diff --git a/src/components/contacts/DirectoryBrowser.tsx b/src/components/contacts/DirectoryBrowser.tsx new file mode 100644 index 00000000..2616ca2c --- /dev/null +++ b/src/components/contacts/DirectoryBrowser.tsx @@ -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(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 = ; + } else if (status === 'unavailable') { + placeholder = ( + } + 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 = ( + } + 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 = ( + } + 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 = ( + } + 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 = ( + } + 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 ( + + e.email.toLowerCase()} + ListHeaderComponent={restrictedNote ? ( + + + {restrictedNote} + + ) : null} + renderItem={({ item }) => { + const kind = typeLabel(item); + return ( + 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' ? ( + + {item.type === 'group' + ? + : } + + ) : ( + + )} + + {item.name} + + {kind ? `${item.email} ยท ${kind}` : item.email} + + + + ); + }} + ItemSeparatorComponent={() => } + ListEmptyComponent={{placeholder}} + refreshControl={ + 0} onRefresh={() => void fetchDirectory()} tintColor={c.primary} /> + } + contentContainerStyle={{ flexGrow: 1 }} + /> + setSheet(null)} /> + + ); +} + +function StatePanel({ styles, icon, title, hint }: { + styles: ReturnType; + icon: React.ReactNode; + title: string; + hint?: string; +}) { + return ( + <> + {icon} + {title} + {!!hint && {hint}} + + ); +} + +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' }, + }); +} diff --git a/src/lib/contact-directory.ts b/src/lib/contact-directory.ts new file mode 100644 index 00000000..a90ae3e7 --- /dev/null +++ b/src/lib/contact-directory.ts @@ -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(); + 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; +} diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx index 39a677a9..f43e2be0 100644 --- a/src/screens/ContactsScreen.tsx +++ b/src/screens/ContactsScreen.tsx @@ -43,6 +43,8 @@ import { } from '../components/contacts'; import Dialog from '../components/Dialog'; import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer'; +import { DirectoryBrowser } from '../components/contacts/DirectoryBrowser'; +import { supportsSharing } from '../api/files'; import { useSettingsStore } from '../stores/settings-store'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; 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 [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 [drawerOpen, setDrawerOpen] = React.useState(false); const [selection, setSelection] = React.useState>(new Set()); @@ -353,7 +359,9 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null } [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 const chips = React.useMemo(() => { @@ -546,18 +554,34 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null } ) : ( setSelectedCategory(chip.category)} - style={[styles.chip, chip.active && styles.chipActive]} + onPress={() => { setShowDirectory(false); setSelectedCategory(chip.category); }} + style={[styles.chip, chip.active && !showDirectory && styles.chipActive]} hitSlop={{ top: 6, bottom: 6 }} accessibilityRole="button" - accessibilityState={{ selected: chip.active }} + accessibilityState={{ selected: chip.active && !showDirectory }} > - + {chip.label} ), )} + {directoryAvailable && ( + <> + + setShowDirectory(true)} + style={[styles.chip, showDirectory && styles.chipActive]} + hitSlop={{ top: 6, bottom: 6 }} + accessibilityRole="button" + accessibilityState={{ selected: showDirectory }} + > + + {t('contacts.directory.title', 'Directory')} + + + + )} )} @@ -567,7 +591,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null } )} - : item.id} renderItem={({ item }) => ( @@ -621,7 +645,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null } ) } - /> + />} ({ }, })); +vi.mock('../../api/files', () => ({ + listPrincipals: vi.fn(), + supportsSharing: vi.fn(() => true), +})); + vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(), getBlobsAsBase64: vi.fn(), @@ -54,6 +59,7 @@ vi.mock('../../api/blob', () => ({ import * as contactsApi from '../../api/contacts'; import * as blobApi from '../../api/blob'; +import * as filesApi from '../../api/files'; import { jmapClient } from '../../api/jmap-client'; import { resetContactBlobMediaRefusals } from '../../lib/contact-media'; import * as recentApi from '../../api/recent-recipients'; @@ -632,6 +638,56 @@ describe('contacts-store', () => { }); }); +describe('company directory (#1253)', () => { + const mockListPrincipals = filesApi.listPrincipals as ReturnType; + 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', () => { function deferred() { let resolve: (v: T) => void = () => {}; diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index 55bbef0b..0afeef89 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -35,6 +35,13 @@ import { matchesContactSearch, } from '../lib/contact-utils'; 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 { CONTACTS_AUTHORITY } from '../device-sync/types'; @@ -81,6 +88,15 @@ export interface ContactsState { recentRecipientsLoaded: boolean; 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; fetchAddressBooks: () => Promise; fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise; @@ -134,6 +150,13 @@ export interface ContactsState { searchRecipients: (query: string) => Promise; getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[]; findContactByEmail: (email: string) => ContactCard | undefined; + /** Load the company directory (the server's principals). */ + fetchDirectory: () => Promise; + /** + * 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 // 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 | null = null; let contactsPromise: Promise | null = null; let trustedSendersPromise: Promise | null = null; +let directoryPromise: Promise | 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, @@ -384,6 +408,10 @@ export const useContactsStore = create()( recentRecipientsLoaded: false, sentMailboxId: null, + directory: [], + directoryStatus: 'idle', + directoryQueriesOff: false, + hydrate: async () => { if (get().hydrated) return; try { @@ -921,6 +949,38 @@ export const useContactsStore = create()( && 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 = (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) => { if (!jmapClient.isConnected) return; // Share the in-flight load so parallel callers (mail view, settings, @@ -1027,6 +1087,7 @@ export const useContactsStore = create()( addressBooksPromise = null; contactsPromise = null; trustedSendersPromise = null; + directoryPromise = null; set({ addressBooks: [], contacts: [], @@ -1041,6 +1102,9 @@ export const useContactsStore = create()( recentRecipients: [], recentRecipientsLoaded: false, sentMailboxId: null, + directory: [], + directoryStatus: 'idle', + directoryQueriesOff: false, }); }, };