From 66f841bc6b132d9049a5858117cb8b3b17614f14 Mon Sep 17 00:00:00 2001 From: tchapi Date: Thu, 12 Nov 2026 23:19:49 +0100 Subject: [PATCH 01/13] [Contacts] Browse the address book: list, address book picker, card, downloads --- frontend/src/ContactsApp.tsx | 181 +++++++ frontend/src/MailApp.tsx | 97 +++- frontend/src/api/contactsHooks.ts | 388 ++++++++++++++ frontend/src/api/dto.ts | 94 ++++ frontend/src/api/mailHooks.ts | 61 +-- frontend/src/components/AppShell.module.css | 34 -- frontend/src/components/AppShell.tsx | 56 +-- frontend/src/components/FolderNav.module.css | 9 +- frontend/src/components/FolderNav.tsx | 68 ++- frontend/src/components/ShortcutsHelp.tsx | 39 +- frontend/src/components/SplitPanes.module.css | 33 ++ frontend/src/components/SplitPanes.tsx | 65 +++ .../contacts/ContactDetail.module.css | 186 +++++++ .../src/components/contacts/ContactDetail.tsx | 360 +++++++++++++ .../contacts/ContactList.module.css | 111 ++++ .../src/components/contacts/ContactList.tsx | 476 ++++++++++++++++++ .../components/contacts/ContactRow.module.css | 63 +++ .../src/components/contacts/ContactRow.tsx | 69 +++ frontend/src/hooks.ts | 60 ++- frontend/src/lib/addressBook.test.ts | 118 +++++ frontend/src/lib/addressBook.ts | 88 ++++ frontend/src/lib/i18n.ts | 191 +++++++ frontend/src/lib/shortcuts.ts | 17 +- frontend/src/lib/url.test.ts | 22 +- frontend/src/lib/url.ts | 31 +- frontend/src/main.tsx | 2 + frontend/src/types.ts | 65 +++ 27 files changed, 2794 insertions(+), 190 deletions(-) create mode 100644 frontend/src/ContactsApp.tsx create mode 100644 frontend/src/api/contactsHooks.ts create mode 100644 frontend/src/components/SplitPanes.module.css create mode 100644 frontend/src/components/SplitPanes.tsx create mode 100644 frontend/src/components/contacts/ContactDetail.module.css create mode 100644 frontend/src/components/contacts/ContactDetail.tsx create mode 100644 frontend/src/components/contacts/ContactList.module.css create mode 100644 frontend/src/components/contacts/ContactList.tsx create mode 100644 frontend/src/components/contacts/ContactRow.module.css create mode 100644 frontend/src/components/contacts/ContactRow.tsx create mode 100644 frontend/src/lib/addressBook.test.ts create mode 100644 frontend/src/lib/addressBook.ts diff --git a/frontend/src/ContactsApp.tsx b/frontend/src/ContactsApp.tsx new file mode 100644 index 0000000..43598ba --- /dev/null +++ b/frontend/src/ContactsApp.tsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useLocalStorage } from '@mantine/hooks' +import { ContactError, useMailHooks, type ContactUrls } from './hooks' +import type { Addr, ContactSummary, Density } from './types' +import { matchesFilter } from './lib/addressBook' +import { useMailShortcuts } from './lib/shortcuts' +import type { Layout } from './components/AppShell' +import { SplitPanes } from './components/SplitPanes' +import { ContactList } from './components/contacts/ContactList' +import { ContactDetail } from './components/contacts/ContactDetail' + +export type ContactsAppProps = { + layout: Layout + density: Density + urls: ContactUrls + /** The open contact lives in the address bar, so MailApp owns it. */ + contactId: string | null + onContactChange: (id: string | null) => void + /** Back to mail */ + onClose: () => void + /** Undefined while nothing can be sent */ + onCompose?: (to?: Addr) => void + onShowMessages: (email: string) => void + onGoInbox: () => void + onShowShortcuts: () => void + /** False while a dialog or the composer has the keyboard */ + shortcutsEnabled: boolean + /** The backend answers */ + reachable: boolean +} + +/** + * The address book: the list of cards on the left, the open card on the right. + * Lazy-loaded with its components; MailApp only knows the view and the open card's id. + */ +export function ContactsApp({ + layout, + density, + urls, + contactId, + onContactChange, + onClose, + onCompose, + onShowMessages, + onGoInbox, + onShowShortcuts, + shortcutsEnabled, + reachable, +}: ContactsAppProps) { + const hooks = useMailHooks() + const actions = hooks.useContactActions() + const bookQuery = hooks.useAddressBook(true) + const cardQuery = hooks.useContactCard(contactId) + const isMobile = layout === 'mobile' + + const [listWidth, setListWidth] = useLocalStorage({ + key: 'postel.contacts-list-width', + defaultValue: 340, + }) + // Which address book the user looks at, remembered by this browser + const [storedBook, setStoredBook] = useLocalStorage({ + key: 'postel.contacts-book', + defaultValue: 'all', + }) + const [filter, setFilter] = useState('') + const filterRef = useRef(null) + const [mobileDetail, setMobileDetail] = useState(contactId !== null) + + const data = bookQuery.data + const books = useMemo(() => data?.books ?? [], [data]) + // A book that is gone, or a single book: show everything + const bookId = books.length > 1 && books.some((b) => b.id === storedBook) ? storedBook : 'all' + const inBook = useMemo( + () => (data?.cards ?? []).filter((c) => bookId === 'all' || c.book === bookId), + [data, bookId], + ) + const cards = useMemo(() => inBook.filter((c) => matchesFilter(c, filter)), [inBook, filter]) + + // On open: show the copy at once, and ask the backend to look for changes behind it + const refreshed = useRef(false) + useEffect(() => { + if (refreshed.current) return + refreshed.current = true + actions.refresh().catch(() => undefined) + }, [actions]) + + const card = cardQuery.data + const photoUrl = (c: Pick) => + c.photo ? urls.photo(c) : undefined + + const open = (id: string) => { + onContactChange(id) + if (isMobile) setMobileDetail(true) + } + + const step = (delta: 1 | -1) => { + if (!cards.length) return + const idx = contactId === null ? -1 : cards.findIndex((c) => c.id === contactId) + const next = cards[Math.min(cards.length - 1, Math.max(0, idx + delta))] + if (next) onContactChange(next.id) + } + + const write = (email?: string) => { + if (!onCompose) return + onCompose(email ? { email, name: card?.name } : undefined) + } + + useMailShortcuts( + { + next: () => step(1), + prev: () => step(-1), + open: () => contactId !== null && open(contactId), + close: () => { + if (isMobile && mobileDetail) setMobileDetail(false) + else onClose() + }, + search: () => filterRef.current?.focus(), + compose: () => write(card?.emails[0]?.value), + goInbox: onGoInbox, + help: onShowShortcuts, + }, + shortcutsEnabled, + ) + + const downloadUrl = bookId === 'all' ? urls.allVcf() : urls.bookVcf(bookId) + const bookName = books.length > 1 ? books.find((b) => b.id === card?.book)?.name : undefined + const problem = cardQuery.error instanceof ContactError ? cardQuery.error.problem : undefined + + const list = ( + void bookQuery.refetch()} + unreachable={!reachable} + stale={data ? !data.available : false} + books={books} + bookId={bookId} + onBookChange={setStoredBook} + filter={filter} + onFilterChange={setFilter} + filterRef={filterRef} + activeId={contactId} + density={density} + isMobile={isMobile} + photoUrl={photoUrl} + downloadUrl={downloadUrl} + onRefresh={() => actions.refresh().catch(() => undefined)} + onActivate={onContactChange} + onOpen={open} + onBack={isMobile ? onClose : undefined} + /> + ) + + const detail = ( + void cardQuery.refetch()} + isMobile={isMobile} + photoUrl={card ? photoUrl(card) : undefined} + vcfUrl={contactId ? urls.cardVcf(contactId) : '#'} + bookName={bookName} + onBack={() => setMobileDetail(false)} + onWrite={onCompose ? write : undefined} + onShowMessages={onShowMessages} + /> + ) + + return ( + + ) +} diff --git a/frontend/src/MailApp.tsx b/frontend/src/MailApp.tsx index ea402ea..e042808 100644 --- a/frontend/src/MailApp.tsx +++ b/frontend/src/MailApp.tsx @@ -4,6 +4,7 @@ import { useLocalStorage, useMediaQuery, useNetwork } from '@mantine/hooks' import { notifications } from '@mantine/notifications' import { spotlight } from '@mantine/spotlight' import { + BookUserIcon, CheckIcon, CircleAlertIcon, KeyboardIcon, @@ -14,8 +15,10 @@ import { WifiOffIcon, } from 'lucide-react' import { rawUrl, realUid } from './api/mailHooks' -import { useMailHooks } from './hooks' +import { contactUrls as apiContactUrls } from './api/contactsHooks' +import { useMailHooks, type ContactUrls } from './hooks' import type { + Addr, Attachment, ComposeMode, DraftInput, @@ -31,7 +34,7 @@ import { I18nContext, STRINGS } from './lib/i18n' import { absoluteDate, displayName } from './lib/format' import { useMailShortcuts } from './lib/shortcuts' import { KnownContactsProvider } from './lib/contacts' -import { parseLocation, toUrl } from './lib/url' +import { parseLocation, placeOf, toUrl, type AppLocation } from './lib/url' import { DEFAULT_NAV, MailAppShell, type Layout } from './components/AppShell' import { TopBar } from './components/TopBar' import { FolderNav, sortFolders } from './components/FolderNav' @@ -48,6 +51,8 @@ import { toMoveTargets } from './components/MoveMenu' // Tiptap only loads when someone writes or opens settings. const Composer = lazy(() => import('./components/Composer').then((m) => ({ default: m.Composer }))) const Settings = lazy(() => import('./components/Settings').then((m) => ({ default: m.Settings }))) +// The address book is its own screen, loaded the first time it opens +const ContactsApp = lazy(() => import('./ContactsApp').then((m) => ({ default: m.ContactsApp }))) const SEND_DELAY_S = 5 @@ -75,11 +80,14 @@ export type MailAppProps = { a: Attachment, disposition: 'inline' | 'download' | 'thumb', ) => string + /** Where contact pictures and .vcf downloads are served from. */ + contactUrls?: ContactUrls } export function MailApp({ attachmentUrl = (ref, a, d) => `/api/folders/${encodeURIComponent(ref.folder)}/messages/${ref.uid}/attachments/${encodeURIComponent(a.id)}?disposition=${d}`, + contactUrls = apiContactUrls, }: MailAppProps) { const hooks = useMailHooks() const { settings, update: updateSettings } = hooks.useSettings() @@ -125,7 +133,12 @@ export function MailApp({ const [mobileReader, setMobileReader] = useState(initial.uid !== null) const [selected, setSelected] = useState>(() => new Set()) const anchor = useRef(null) - const [view, setView] = useState<'mail' | 'settings'>(initial.view) + const [requestedView, setView] = useState(initial.view) + const [contactId, setContactId] = useState(initial.contactId) + // Undefined until the session is known: the contacts view waits rather than flashing the inbox + const contactsEnabled = session.contacts + // No address book: /contacts is the inbox + const view = requestedView === 'contacts' && contactsEnabled === false ? 'mail' : requestedView const [helpOpen, setHelpOpen] = useState(false) const [sourceRef, setSourceRef] = useState(null) const sourceQuery = hooks.useRawMessage(sourceRef) @@ -206,15 +219,16 @@ export function MailApp({ // The address bar follows the app: a new place is a new history entry, opening a message replaces it. const lastUrl = useRef(toUrl(initial)) useEffect(() => { - const url = toUrl({ view, folderId, uid: search ? null : activeUid, search }) + const url = toUrl({ view, folderId, uid: search ? null : activeUid, search, contactId }) if (url === lastUrl.current) return - const samePlace = - url.split('?')[0]!.split('/').slice(0, 3).join('/') === - lastUrl.current.split('?')[0]!.split('/').slice(0, 3).join('/') - if (samePlace && !url.startsWith('/search')) window.history.replaceState(null, '', url) + const samePlace = placeOf(url) === placeOf(lastUrl.current) + // A /contacts that became the inbox takes the place of the address that asked for it + const redirected = view !== requestedView + if ((samePlace && !url.startsWith('/search')) || redirected) + window.history.replaceState(null, '', url) else window.history.pushState(null, '', url) lastUrl.current = url - }, [view, folderId, activeUid, search]) + }, [view, requestedView, folderId, activeUid, search, contactId]) // Back and forward: the app follows the address bar. const place = useRef({ folderId, search }) @@ -234,6 +248,7 @@ export function MailApp({ setFolderId(location.folderId) setSearch(location.search) setActiveUid(location.uid) + setContactId(location.contactId) setSelected(new Set()) } window.addEventListener('popstate', onPop) @@ -560,6 +575,21 @@ export function MailApp({ const toggleTheme = () => setColorScheme(computed === 'dark' ? 'light' : 'dark') + const goContacts = () => { + setView('contacts') + setDrawerOpened(false) + } + + const composeTo = (to?: Addr) => + openCompose('new', undefined, { + to: to ? [to] : [], + cc: [], + bcc: [], + subject: '', + html: '', + attachmentIds: [], + }) + /* ---------- keyboard ---------- */ useMailShortcuts( { @@ -586,9 +616,11 @@ export function MailApp({ compose: () => openCompose('new'), search: () => spotlight.open(), goInbox: () => goFolder(roleId('inbox') ?? 'INBOX'), + goContacts: contactsEnabled ? goContacts : undefined, help: () => setHelpOpen(true), }, - compose === null && !helpOpen, + // The address book has its own keys + compose === null && !helpOpen && view !== 'contacts', ) /* ---------- derived props ---------- */ @@ -688,7 +720,29 @@ export function MailApp({ ) const page = - view === 'settings' ? ( + view === 'contacts' ? ( + contactsEnabled ? ( + + setView('mail')} + onCompose={reachable ? composeTo : undefined} + onShowMessages={runSearch} + onGoInbox={() => goFolder(roleId('inbox') ?? 'INBOX')} + onShowShortcuts={() => setHelpOpen(true)} + shortcutsEnabled={compose === null && !helpOpen} + reachable={reachable} + /> + + ) : ( + // Waiting to know whether there is an address book at all +
+ ) + ) : view === 'settings' ? ( void foldersQuery.refetch()} - activeId={search ? null : folderId} + activeId={search || view === 'contacts' ? null : folderId} collapsed={collapsed} onSelect={goFolder} onToggleCollapsed={ @@ -747,6 +801,8 @@ export function MailApp({ onDropMessages={ layout === 'mobile' ? undefined : (id, uids) => void runAction({ moveTo: id }, uids) } + onContacts={contactsEnabled ? goContacts : undefined} + contactsActive={view === 'contacts'} /> )} list={list} @@ -819,6 +875,16 @@ export function MailApp({ label: computed === 'dark' ? t.lightTheme : t.darkTheme, icon: , }, + ...(contactsEnabled + ? [ + { + id: 'contacts', + label: t.contact.title, + icon: , + shortcut: 'goContacts' as const, + }, + ] + : []), { id: 'settings', label: t.settings, @@ -837,11 +903,16 @@ export function MailApp({ if (id === 'compose') openCompose('new') if (id === 'theme') toggleTheme() if (id === 'settings') setView('settings') + if (id === 'contacts') goContacts() if (id === 'shortcuts') setHelpOpen(true) }} /> - setHelpOpen(false)} /> + setHelpOpen(false)} + contacts={contactsEnabled} + /> ['contact-card', id] as const + +const cardPath = (id: string) => `/contacts/cards/${encodeURIComponent(id)}` +const bookPath = (id: string) => `/contacts/books/${encodeURIComponent(id)}` + +export const contactUrls: ContactUrls = { + // The picture is cached for a year: the ETag in the URL changes with the card + photo: ({ id, etag }) => `/api${cardPath(id)}/photo?v=${encodeURIComponent(etag)}`, + cardVcf: (id) => `/api${cardPath(id)}/vcf`, + bookVcf: (id) => `/api${bookPath(id)}/vcf`, + allVcf: () => '/api/contacts/vcf', +} + +/* ---------- errors ---------- */ + +function toContactError(error: unknown): ContactError { + if (error instanceof ContactError) return error + if (error instanceof ApiError) { + if (error.code === 'contact_changed' || error.status === 409) return new ContactError('changed') + if (error.code === 'contacts_unavailable') return new ContactError('unavailable') + if (error.code === 'contact_not_found') return new ContactError('notFound') + } + return new ContactError('failed') +} + +async function call(path: string, init?: RequestInit): Promise { + try { + return await api(path, init) + } catch (error) { + throw toContactError(error) + } +} + +const json = (method: string, body: unknown): RequestInit => ({ + method, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, +}) + +const multipart = (method: string, fields: Record, fileName?: string) => { + const body = new FormData() + for (const [name, value] of Object.entries(fields)) { + if (typeof value === 'string') body.append(name, value) + else body.append(name, value, fileName) + } + return { method, body } +} + +/* ---------- mapping between the API and the interface ---------- */ + +const TYPES: ContactType[] = ['home', 'work', 'cell', 'other'] +const toType = (type: Dto.CardType | null): ContactType => + type && TYPES.includes(type) ? type : 'other' + +function toSummary(dto: Dto.CardSummary): ContactSummary { + return { + id: dto.id, + book: dto.book, + etag: dto.etag, + name: dto.name || dto.org || dto.emails[0] || dto.phones[0] || '', + org: dto.org ?? '', + emails: dto.emails, + phones: dto.phones, + photo: dto.photo, + } +} + +function toBook(dto: Dto.AddressBookList): AddressBookData { + return { + available: dto.available, + books: dto.books, + cards: sortCards(dto.cards.map(toSummary)), + } +} + +function toCard(dto: Dto.Card): ContactCard { + return { + id: dto.id, + book: dto.book, + etag: dto.etag, + name: dto.name || dto.org || dto.emails[0]?.value || '', + firstName: dto.firstName ?? '', + lastName: dto.lastName ?? '', + org: dto.org ?? '', + title: dto.title ?? '', + emails: dto.emails.map((e) => ({ + key: e.key, + value: e.value, + type: toType(e.type), + label: e.label ?? undefined, + })), + phones: dto.phones.map((p) => ({ + key: p.key, + value: p.value, + type: toType(p.type), + label: p.label ?? undefined, + })), + addresses: dto.addresses.map((a) => ({ + key: a.key, + type: toType(a.type), + street: a.street ?? '', + city: a.city ?? '', + region: a.region ?? '', + postalCode: a.postalCode ?? '', + country: a.country ?? '', + })), + urls: dto.urls.map((u) => ({ key: u.key, value: u.value })), + birthday: dto.birthday ?? '', + note: dto.note ?? '', + photo: dto.photo, + } +} + +/** What a list row needs, from a whole card. */ +function summaryOf(card: ContactCard): ContactSummary { + return { + id: card.id, + book: card.book, + etag: card.etag, + name: card.name, + org: card.org, + emails: card.emails.map((e) => e.value), + phones: card.phones.map((p) => p.value), + photo: card.photo, + } +} + +const orNull = (value: string) => value.trim() || null +const withKey = (key: number | undefined) => (key === undefined ? {} : { key }) + +/** Empty lines are dropped: the editor shows an empty row to type in, the card should not keep it. */ +function fromInput(input: ContactInput): Dto.CardInput { + return { + firstName: orNull(input.firstName), + lastName: orNull(input.lastName), + org: orNull(input.org), + title: orNull(input.title), + emails: input.emails + .filter((e) => e.value.trim()) + .map((e) => ({ ...withKey(e.key), value: e.value.trim(), type: e.type })), + phones: input.phones + .filter((p) => p.value.trim()) + .map((p) => ({ ...withKey(p.key), value: p.value.trim(), type: p.type })), + addresses: input.addresses + .filter((a) => [a.street, a.city, a.region, a.postalCode, a.country].some((v) => v.trim())) + .map((a) => ({ + ...withKey(a.key), + type: a.type, + street: orNull(a.street), + city: orNull(a.city), + region: orNull(a.region), + postalCode: orNull(a.postalCode), + country: orNull(a.country), + })), + urls: input.urls + .filter((u) => u.value.trim()) + .map((u) => ({ ...withKey(u.key), value: u.value.trim() })), + birthday: orNull(input.birthday), + note: input.note.trim() ? input.note : null, + } +} + +/* ---------- names and pictures of the people on screen ---------- */ + +/* + * Looked up once per address and kept for the session, in a store outside React so that a + * write to the address book can mark them all stale. Stale entries stay on screen until their + * new answer arrives, so names and pictures do not flicker. + */ +type NameEntry = { contact: KnownContact | null; generation: number } +let names = new Map() +let generation = 0 +const nameListeners = new Set<() => void>() +const notifyNames = () => nameListeners.forEach((listener) => listener()) + +const nameStore = { + get: () => names, + subscribe: (listener: () => void) => { + nameListeners.add(listener) + return () => void nameListeners.delete(listener) + }, + set(emails: string[], found: Record, at: number) { + names = new Map(names) + for (const email of emails) { + const card = found[email] + names.set(email, { + generation: at, + contact: card + ? { + email, + name: card.name ?? undefined, + // The generation in the URL fetches a picture again after a change + photo: card.photo + ? `/api/contacts/photo?email=${encodeURIComponent(email)}&v=${at}` + : undefined, + } + : null, + }) + } + notifyNames() + }, + stale() { + generation += 1 + names = new Map(names) + notifyNames() + }, +} + +function useContactNames(emails: string[]): ReadonlyMap { + const known = useSyncExternalStore(nameStore.subscribe, nameStore.get) + const current = generation + const missing = useMemo(() => { + const set = new Set() + for (const e of emails) { + const email = e.trim().toLowerCase() + const entry = known.get(email) + if (email && (!entry || entry.generation < current)) set.add(email) + } + return [...set].sort() + }, [emails, known, current]) + const { data } = useQuery({ + queryKey: ['contact-names', current, missing], + queryFn: async () => { + const query = missing.map((e) => `emails[]=${encodeURIComponent(e)}`).join('&') + return api>(`/contacts/lookup?${query}`) + }, + enabled: missing.length > 0, + staleTime: Infinity, + }) + useEffect(() => { + if (data) nameStore.set(missing, data, current) + }, [data, missing, current]) + return useMemo(() => { + const out = new Map() + for (const [email, entry] of known) if (entry.contact) out.set(email, entry.contact) + return out + }, [known]) +} + +/* ---------- the hooks ---------- */ + +export function createContactsHooks( + qc: QueryClient, +): ContactsHooks & Pick { + /** After any write: autocomplete and the names on mail may have changed too. */ + const afterWrite = () => { + void qc.invalidateQueries({ queryKey: ['contacts'] }) + nameStore.stale() + } + + const patchList = (fn: (cards: ContactSummary[]) => ContactSummary[]) => + qc.setQueryData(BOOK_KEY, (data) => + data ? { ...data, cards: sortCards(fn(data.cards)) } : data, + ) + + /** The server's answer to a write is the card as it now is: the list and the detail follow it. */ + const store = (dto: Dto.Card): ContactCard => { + const card = toCard(dto) + qc.setQueryData(cardKey(card.id), card) + patchList((cards) => [...cards.filter((c) => c.id !== card.id), summaryOf(card)]) + afterWrite() + return card + } + + const reloadBook = () => + Promise.all([ + qc.invalidateQueries({ queryKey: BOOK_KEY }), + qc.invalidateQueries({ queryKey: ['contact-card'] }), + ]) + + const actions: ContactActions = { + async create(book, input) { + return store( + await call('/contacts/cards', json('POST', { book, ...fromInput(input) })), + ) + }, + + async update(id, etag, input) { + return store(await call(cardPath(id), json('PUT', { etag, ...fromInput(input) }))) + }, + + async remove({ id, book, etag }) { + const { vcard } = await call( + `${cardPath(id)}?etag=${encodeURIComponent(etag)}`, + { method: 'DELETE' }, + ) + patchList((cards) => cards.filter((c) => c.id !== id)) + qc.removeQueries({ queryKey: cardKey(id) }) + afterWrite() + const undo: Undo = async () => { + await actions.importFile(book, new File([vcard], 'contact.vcf', { type: 'text/vcard' })) + } + return undo + }, + + async setPhoto(id, etag, jpeg) { + return store( + await call( + `${cardPath(id)}/photo`, + multipart('PUT', { etag, file: jpeg }, 'photo.jpg'), + ), + ) + }, + + async removePhoto(id, etag) { + return store( + await call(`${cardPath(id)}/photo?etag=${encodeURIComponent(etag)}`, { + method: 'DELETE', + }), + ) + }, + + async importFile(book, file) { + const result = await call( + `${bookPath(book)}/import`, + multipart('POST', { file }, file.name), + ) + await reloadBook() + afterWrite() + return result + }, + + async refresh() { + const result = await call('/contacts/refresh', { method: 'POST' }) + if (result.changed) { + await reloadBook() + afterWrite() + } else { + qc.setQueryData(BOOK_KEY, (data) => + data ? { ...data, available: result.available } : data, + ) + } + return result + }, + } + + return { + useAddressBook: (enabled) => + useQuery({ + queryKey: BOOK_KEY, + queryFn: async () => toBook(await call('/contacts/cards')), + enabled, + }), + + useContactCard: (id) => + useQuery({ + queryKey: cardKey(id ?? ''), + queryFn: async () => toCard(await call(cardPath(id!))), + enabled: id !== null, + // A card that is gone stays gone + retry: (count, error) => + !(error instanceof ContactError && error.problem === 'notFound') && count < 1, + }), + + useContactActions: () => actions, + + useContactNames, + } +} diff --git a/frontend/src/api/dto.ts b/frontend/src/api/dto.ts index 2dec3df..c4b03ef 100644 --- a/frontend/src/api/dto.ts +++ b/frontend/src/api/dto.ts @@ -16,6 +16,8 @@ export type Me = { email: string displayName: string | null logoutUrl: string | null + /** An address book is configured. Older backends do not send it. */ + contacts?: boolean } export type Address = { @@ -108,3 +110,95 @@ export type Upload = { id: string; name: string; mime: string; size: number } /** GET /api/contacts?q= */ export type Contact = { name: string | null; email: string } + +/* ---------- address book (GET/POST/PUT/DELETE /api/contacts/…) ---------- */ + +/** GET /api/contacts/lookup: one entry per address that has a card */ +export type LookupEntry = { id?: string; name: string | null; photo: boolean } + +export type AddressBook = { id: string; name: string } + +export type CardSummary = { + id: string + book: string + etag: string + name: string | null + org: string | null + emails: string[] + phones: string[] + photo: boolean +} + +/** GET /api/contacts/cards */ +export type AddressBookList = { available: boolean; books: AddressBook[]; cards: CardSummary[] } + +/** POST /api/contacts/refresh */ +export type RefreshResult = { available: boolean; changed: boolean } + +export type CardType = 'home' | 'work' | 'cell' | 'other' + +export type CardValue = { key: number; value: string; type: CardType | null; label: string | null } + +export type CardAddress = { + key: number + type: CardType | null + street: string | null + city: string | null + region: string | null + postalCode: string | null + country: string | null +} + +export type CardUrl = { key: number; value: string } + +/** GET /api/contacts/cards/{id}, and the answer to every write */ +export type Card = { + id: string + book: string + etag: string + name: string | null + firstName: string | null + lastName: string | null + org: string | null + title: string | null + emails: CardValue[] + phones: CardValue[] + addresses: CardAddress[] + urls: CardUrl[] + birthday: string | null + note: string | null + photo: boolean +} + +/** + * The body of POST (with `book`) and PUT (with `etag`). Entries without `key` are new; + * an entry of the card whose key is missing is removed. + */ +export type CardInput = { + book?: string + etag?: string + firstName: string | null + lastName: string | null + org: string | null + title: string | null + emails: { key?: number; value: string; type: CardType }[] + phones: { key?: number; value: string; type: CardType }[] + addresses: { + key?: number + type: CardType + street: string | null + city: string | null + region: string | null + postalCode: string | null + country: string | null + }[] + urls: { key?: number; value: string }[] + birthday: string | null + note: string | null +} + +/** DELETE /api/contacts/cards/{id}: the card as it was, to put it back */ +export type DeletedCard = { vcard: string } + +/** POST /api/contacts/books/{id}/import */ +export type ImportResult = { imported: number; failed: number } diff --git a/frontend/src/api/mailHooks.ts b/frontend/src/api/mailHooks.ts index 2d4ce06..126ef58 100644 --- a/frontend/src/api/mailHooks.ts +++ b/frontend/src/api/mailHooks.ts @@ -5,10 +5,9 @@ import { useInfiniteQuery, useQuery, } from '@tanstack/react-query' -import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import { useSyncExternalStore } from 'react' import type { MailActions, MailHooks } from '../hooks' -import type { KnownContact } from '../lib/contacts' import { ACCENTS, DEFAULT_ACCENT, type AccentName } from '../theme' import type { DateRange, @@ -23,6 +22,7 @@ import type { Undo, } from '../types' import { api, serverReachable } from './client' +import { createContactsHooks } from './contactsHooks' import type * as Dto from './dto' const PAGE = 50 @@ -447,6 +447,8 @@ export function createMailHooks(qc: QueryClient): MailHooks { } return { + ...createContactsHooks(qc), + useFolders: () => useQuery({ queryKey: ['folders'], @@ -534,57 +536,6 @@ export function createMailHooks(qc: QueryClient): MailHooks { placeholderData: keepPreviousData, }), - useContactNames: (emails) => { - // Looked up once per address, then kept for the session - const [known, setKnown] = useState>(() => new Map()) - const missing = useMemo(() => { - const set = new Set() - for (const e of emails) { - const email = e.trim().toLowerCase() - if (email && !known.has(email)) set.add(email) - } - return [...set].sort() - }, [emails, known]) - const { data } = useQuery({ - queryKey: ['contact-names', missing], - queryFn: async () => { - const query = missing.map((e) => `emails[]=${encodeURIComponent(e)}`).join('&') - return api>( - `/contacts/lookup?${query}`, - ) - }, - enabled: missing.length > 0, - staleTime: Infinity, - }) - useEffect(() => { - if (!data) return - setKnown((prev) => { - const next = new Map(prev) - for (const email of missing) { - const card = data[email] - next.set( - email, - card - ? { - email, - name: card.name ?? undefined, - photo: card.photo - ? `/api/contacts/photo?email=${encodeURIComponent(email)}` - : undefined, - } - : null, - ) - } - return next - }) - }, [data, missing]) - return useMemo(() => { - const out = new Map() - for (const [email, contact] of known) if (contact) out.set(email, contact) - return out - }, [known]) - }, - useSettings: () => { const { data } = useQuery({ queryKey: settingsQueryKey, @@ -608,7 +559,7 @@ export function createMailHooks(qc: QueryClient): MailHooks { useServerReachable: () => useSyncExternalStore(serverReachable.subscribe, serverReachable.get), useSession: () => { - const { data } = useQuery({ + const { data, isError } = useQuery({ queryKey: ['me'], queryFn: () => api('/me'), staleTime: Infinity, @@ -616,6 +567,8 @@ export function createMailHooks(qc: QueryClient): MailHooks { return { name: data?.displayName ?? data?.username ?? '', email: data?.email ?? '', + // Without an answer, nothing about contacts is offered + contacts: data ? data.contacts === true : isError ? false : undefined, signOut: () => { if (data?.logoutUrl) window.location.assign(data.logoutUrl) else window.location.reload() diff --git a/frontend/src/components/AppShell.module.css b/frontend/src/components/AppShell.module.css index ef7e685..e58c265 100644 --- a/frontend/src/components/AppShell.module.css +++ b/frontend/src/components/AppShell.module.css @@ -4,40 +4,6 @@ min-height: 0; } -.listPane { - flex: none; - min-width: 0; - display: flex; - flex-direction: column; -} - -.splitter { - flex: none; - width: 1px; - position: relative; - background: var(--mantine-color-default-border); - cursor: col-resize; - touch-action: none; - - /* Wider invisible hit area. */ - &::after { - content: ''; - position: absolute; - inset: 0 -4px; - } - - &:focus-visible { - outline: 2px solid var(--mantine-primary-color-filled); - } -} - -.readerPane { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; -} - .drawerContent { display: flex; flex-direction: column; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index c02d8ae..e1d8fb6 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,6 +1,7 @@ import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react' import { AppShell, Drawer } from '@mantine/core' import { useI18n } from '../lib/i18n' +import { SplitPanes } from './SplitPanes' import { Brand } from './TopBar' import classes from './AppShell.module.css' @@ -19,7 +20,7 @@ export type MailAppShellProps = { renderNav: (collapsed: boolean) => ReactNode list: ReactNode reader: ReactNode - /** Replaces list + reader (settings). */ + /** Replaces list + reader (settings, contacts). */ page?: ReactNode mobilePane: 'list' | 'reader' listWidth: number @@ -34,17 +35,12 @@ const MIN_NAV = 180 const MAX_NAV = 360 const clampNav = (w: number) => Math.min(MAX_NAV, Math.max(MIN_NAV, w)) -const MIN_LIST = 300 -const MAX_LIST = 560 -const clamp = (w: number) => Math.min(MAX_LIST, Math.max(MIN_LIST, w)) - /** * ≥1200 three panes · 768–1199 two panes + folder drawer · <768 one pane. * Built on Mantine AppShell; below `lg` the navbar is collapsed and folders move to a Drawer. */ export function MailAppShell(p: MailAppShellProps) { const { t } = useI18n() - const drag = useRef<{ x: number; w: number } | null>(null) const desktop = p.layout === 'desktop' const navDrag = useRef<{ x: number; w: number } | null>(null) // No width animation while the pane follows the pointer @@ -68,47 +64,15 @@ export function MailAppShell(p: MailAppShellProps) { if (e.key === 'ArrowRight') p.onNavWidthChange(clampNav(p.navWidth + 16)) } - const onSplitDown = (e: PointerEvent) => { - e.currentTarget.setPointerCapture(e.pointerId) - drag.current = { x: e.clientX, w: p.listWidth } - } - const onSplitMove = (e: PointerEvent) => { - if (drag.current) p.onListWidthChange(clamp(drag.current.w + e.clientX - drag.current.x)) - } - const onSplitKey = (e: KeyboardEvent) => { - if (e.key === 'ArrowLeft') p.onListWidthChange(clamp(p.listWidth - 16)) - if (e.key === 'ArrowRight') p.onListWidthChange(clamp(p.listWidth + 16)) - } - const panes = p.page ?? ( - <> - {(p.layout !== 'mobile' || p.mobilePane === 'list') && ( -
- {p.list} -
- )} - {p.layout !== 'mobile' && ( -
(drag.current = null)} - onKeyDown={onSplitKey} - /> - )} - {(p.layout !== 'mobile' || p.mobilePane === 'reader') && ( -
{p.reader}
- )} - + ) return ( diff --git a/frontend/src/components/FolderNav.module.css b/frontend/src/components/FolderNav.module.css index 6b7c9b1..4354e16 100644 --- a/frontend/src/components/FolderNav.module.css +++ b/frontend/src/components/FolderNav.module.css @@ -309,7 +309,14 @@ } } -.footer .link { +.footer { + display: flex; + flex-direction: column; + gap: 2px; +} + +/* The toggle is chrome, not a place: quieter than the rows above it */ +.toggle { color: var(--mantine-color-dimmed); } diff --git a/frontend/src/components/FolderNav.tsx b/frontend/src/components/FolderNav.tsx index 7cceecd..62c7a3a 100644 --- a/frontend/src/components/FolderNav.tsx +++ b/frontend/src/components/FolderNav.tsx @@ -5,6 +5,7 @@ import { useLocalStorage } from '@mantine/hooks' import { ArchiveIcon, BanIcon, + BookUserIcon, ChevronRightIcon, FileTextIcon, FolderIcon, @@ -111,6 +112,10 @@ export type FolderNavProps = { composeDisabled?: boolean /** Messages dropped on a folder, dragged from the list. Omit where there is nothing to drag. */ onDropMessages?: (folderId: string, uids: number[]) => void + /** Opens the address book. Omit when none is configured. */ + onContacts?: () => void + /** The address book is open: its row is the current one, no folder is. */ + contactsActive?: boolean } export function FolderNav({ @@ -124,6 +129,8 @@ export function FolderNav({ onCompose, composeDisabled, onDropMessages, + onContacts, + contactsActive, }: FolderNavProps) { const { t } = useI18n() const label = useFolderLabel() @@ -215,10 +222,50 @@ export function FolderNav({
) : null + // Contacts and the collapse toggle stay at the bottom, whatever the folders are doing + const footer = + onContacts || onToggleCollapsed ? ( +
+ {onContacts && ( + + + + {t.contact.title} + + + )} + {onToggleCollapsed && ( + + + + {t.collapse} + + + )} +
+ ) : null + const shell = (children: React.ReactNode) => ( {compose} - {children} + + {children} + + {footer} ) @@ -322,25 +369,6 @@ export function FolderNav({ {custom.map((f) => renderLink(f))} - {onToggleCollapsed && ( -
- - - - {t.collapse} - - -
- )} , ) } diff --git a/frontend/src/components/ShortcutsHelp.tsx b/frontend/src/components/ShortcutsHelp.tsx index a24df1a..07cf5e0 100644 --- a/frontend/src/components/ShortcutsHelp.tsx +++ b/frontend/src/components/ShortcutsHelp.tsx @@ -1,6 +1,6 @@ import { Modal, SimpleGrid, Stack, Table, Text } from '@mantine/core' import { useI18n } from '../lib/i18n' -import { SHORTCUTS, type ShortcutId } from '../lib/shortcuts' +import { CONTACT_SHORTCUTS, SHORTCUTS, type ShortcutId } from '../lib/shortcuts' import { KeyCombo } from './ShortcutTooltip' const GROUPS = ['move', 'act', 'app'] as const @@ -11,6 +11,7 @@ const ORDER: ShortcutId[] = [ 'open', 'close', 'goInbox', + 'goContacts', 'search', 'palette', 'reply', @@ -20,14 +21,16 @@ const ORDER: ShortcutId[] = [ 'delete', 'star', 'unread', + 'editContact', 'compose', 'send', 'help', ] -/** The cheat-sheet content; also printable on its own. */ -export function ShortcutsTable() { +/** The cheat-sheet content; also printable on its own. `contacts`: list the address book's keys too. */ +export function ShortcutsTable({ contacts = false }: { contacts?: boolean }) { const { t } = useI18n() + const shown = ORDER.filter((id) => contacts || !CONTACT_SHORTCUTS.includes(id)) return ( {GROUPS.map((g) => ( @@ -37,14 +40,16 @@ export function ShortcutsTable() { - {ORDER.filter((id) => SHORTCUTS[id].group === g).map((id) => ( - - - - - {t.shortcutLabels[id]} - - ))} + {shown + .filter((id) => SHORTCUTS[id].group === g) + .map((id) => ( + + + + + {t.shortcutLabels[id]} + + ))}
@@ -53,12 +58,20 @@ export function ShortcutsTable() { ) } -export function ShortcutsHelp({ opened, onClose }: { opened: boolean; onClose: () => void }) { +export function ShortcutsHelp({ + opened, + onClose, + contacts, +}: { + opened: boolean + onClose: () => void + contacts?: boolean +}) { const { t } = useI18n() return ( - + {t.shortcutsFooter} diff --git a/frontend/src/components/SplitPanes.module.css b/frontend/src/components/SplitPanes.module.css new file mode 100644 index 0000000..cfdb742 --- /dev/null +++ b/frontend/src/components/SplitPanes.module.css @@ -0,0 +1,33 @@ +.listPane { + flex: none; + min-width: 0; + display: flex; + flex-direction: column; +} + +.splitter { + flex: none; + width: 1px; + position: relative; + background: var(--mantine-color-default-border); + cursor: col-resize; + touch-action: none; + + /* Wider invisible hit area. */ + &::after { + content: ''; + position: absolute; + inset: 0 -4px; + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + } +} + +.readerPane { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} diff --git a/frontend/src/components/SplitPanes.tsx b/frontend/src/components/SplitPanes.tsx new file mode 100644 index 0000000..52d265e --- /dev/null +++ b/frontend/src/components/SplitPanes.tsx @@ -0,0 +1,65 @@ +import { useRef, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react' +import type { Layout } from './AppShell' +import classes from './SplitPanes.module.css' + +export type SplitPanesProps = { + layout: Layout + list: ReactNode + reader: ReactNode + /** Phones show one pane at a time. */ + mobilePane: 'list' | 'reader' + listWidth: number + onListWidthChange: (w: number) => void +} + +const MIN_LIST = 300 +const MAX_LIST = 560 +const clamp = (w: number) => Math.min(MAX_LIST, Math.max(MIN_LIST, w)) + +/** A list and what it opens, side by side, with a splitter between them. Mail and contacts share it. */ +export function SplitPanes(p: SplitPanesProps) { + const drag = useRef<{ x: number; w: number } | null>(null) + + const onSplitDown = (e: PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId) + drag.current = { x: e.clientX, w: p.listWidth } + } + const onSplitMove = (e: PointerEvent) => { + if (drag.current) p.onListWidthChange(clamp(drag.current.w + e.clientX - drag.current.x)) + } + const onSplitKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') p.onListWidthChange(clamp(p.listWidth - 16)) + if (e.key === 'ArrowRight') p.onListWidthChange(clamp(p.listWidth + 16)) + } + + return ( + <> + {(p.layout !== 'mobile' || p.mobilePane === 'list') && ( +
+ {p.list} +
+ )} + {p.layout !== 'mobile' && ( +
(drag.current = null)} + onKeyDown={onSplitKey} + /> + )} + {(p.layout !== 'mobile' || p.mobilePane === 'reader') && ( +
{p.reader}
+ )} + + ) +} diff --git a/frontend/src/components/contacts/ContactDetail.module.css b/frontend/src/components/contacts/ContactDetail.module.css new file mode 100644 index 0000000..d154db2 --- /dev/null +++ b/frontend/src/components/contacts/ContactDetail.module.css @@ -0,0 +1,186 @@ +.root { + position: relative; + min-width: 0; +} + +/* The toolbar floats over the card as it does over a message */ +.viewport, +.scrollbar { + --bar-space: 50px; + + @media (max-width: $mantine-breakpoint-sm) { + --bar-space: 62px; + } +} + +.viewport { + padding-top: var(--bar-space); + mask-image: linear-gradient(to bottom, transparent 4px, #000 calc(var(--bar-space) + 6px)); +} + +.scrollbar { + margin-top: var(--bar-space); +} + +.bar { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; + margin: 6px 6px 0; + border-radius: var(--mantine-radius-md); + backdrop-filter: blur(14px) saturate(140%); + transition: box-shadow 150ms ease; + + @mixin light { + background: color-mix( + in srgb, + color-mix(in srgb, var(--mantine-color-gray-1) 55%, var(--mantine-color-body)) 78%, + transparent + ); + box-shadow: + 0 1px 2px rgb(31 29 26 / 6%), + 0 2px 8px -2px rgb(31 29 26 / 10%); + } + + @mixin dark { + background: color-mix( + in srgb, + color-mix(in srgb, var(--mantine-color-dark-8) 55%, var(--mantine-color-body)) 78%, + transparent + ); + box-shadow: + 0 1px 2px rgb(0 0 0 / 30%), + 0 2px 8px -2px rgb(0 0 0 / 40%); + } + + &[data-scrolled] { + @mixin light { + box-shadow: + 0 1px 2px rgb(31 29 26 / 8%), + 0 6px 14px -6px rgb(31 29 26 / 22%); + } + + @mixin dark { + box-shadow: + 0 1px 2px rgb(0 0 0 / 35%), + 0 6px 16px -6px rgb(0 0 0 / 60%); + } + } +} + +.toolbar { + height: 44px; + + @media (max-width: $mantine-breakpoint-sm) { + height: 56px; + } +} + +/* Everything on a card can be selected and copied. A card is short: one column, rules included */ +.body { + max-width: 760px; + user-select: text; + cursor: auto; +} + +.header { + display: flex; + align-items: center; + gap: 18px; + padding-bottom: 20px; + border-bottom: 1px solid var(--mantine-color-default-border); +} + +.name { + font-size: 1.375rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +/* Emails, phones, addresses…: one group each, a thin rule between groups */ +.group { + padding-block: 8px; + + & + & { + border-top: 1px solid var(--mantine-color-default-border); + } +} + +.field { + display: grid; + grid-template-columns: 112px minmax(0, 1fr) 28px; + column-gap: 16px; + align-items: start; + padding-block: 6px; + + @media (max-width: $mantine-breakpoint-sm) { + grid-template-columns: 84px minmax(0, 1fr) 28px; + column-gap: 12px; + } +} + +.fieldLabel { + line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} + +.fieldValue { + min-width: 0; + line-height: 22px; + overflow-wrap: anywhere; + + & > * { + line-height: 22px; + } +} + +.link { + font-size: var(--mantine-font-size-sm); + color: var(--mantine-primary-color-light-color); + text-align: left; + overflow-wrap: anywhere; + + @mixin hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; + border-radius: 2px; + } +} + +.multiline { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* The copy button only shows on the line under the pointer or the keyboard */ +.copy { + margin-top: 1px; + color: var(--mantine-color-dimmed); + opacity: 0; + transition: opacity 120ms ease; + + .field:hover &, + .field:focus-within &, + &[data-copied] { + opacity: 1; + } + + @media (hover: none) { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .copy { + transition: none; + } +} diff --git a/frontend/src/components/contacts/ContactDetail.tsx b/frontend/src/components/contacts/ContactDetail.tsx new file mode 100644 index 0000000..6c6088d --- /dev/null +++ b/frontend/src/components/contacts/ContactDetail.tsx @@ -0,0 +1,360 @@ +import { useState, type ReactNode } from 'react' +import { + ActionIcon, + Anchor, + Avatar, + Box, + CopyButton, + Group, + Menu, + ScrollArea, + Stack, + Text, + Title, + Tooltip, + UnstyledButton, +} from '@mantine/core' +import { + ArrowLeftIcon, + BookUserIcon, + CheckIcon, + CopyIcon, + DownloadIcon, + EllipsisIcon, + EllipsisVerticalIcon, + InboxIcon, + PencilIcon, + SquarePenIcon, + Trash2Icon, + UserXIcon, +} from 'lucide-react' +import type { ContactCard, ContactType } from '../../types' +import type { ContactProblem } from '../../hooks' +import { formatBirthday } from '../../lib/addressBook' +import { initials } from '../../lib/format' +import { useI18n } from '../../lib/i18n' +import { KeyCombo, ShortcutTooltip } from '../ShortcutTooltip' +import { ErrorState, ReaderSkeleton, StateMessage } from '../States' +import classes from './ContactDetail.module.css' + +export type ContactDetailProps = { + card: ContactCard | undefined + status: 'idle' | 'pending' | 'error' | 'success' + problem?: ContactProblem + onRetry: () => void + isMobile: boolean + photoUrl?: string + vcfUrl: string + /** Shown under the name when there are several address books. */ + bookName?: string + onBack?: () => void + /** Opens the composer to this address. Omit when nothing can be sent. */ + onWrite?: (email: string) => void + onShowMessages: (email: string) => void + /** Omit when the card cannot be changed. */ + onEdit?: () => void + onDelete?: () => void +} + +const isWebLink = (url: string) => /^https?:\/\//i.test(url.trim()) + +export function ContactDetail(props: ContactDetailProps) { + const { t } = useI18n() + const { card, status, problem } = props + // The toolbar's shadow shows once the card scrolls under it; a new card starts unscrolled + const [scrolledId, setScrolledId] = useState(null) + const scrolled = scrolledId !== null && scrolledId === card?.id + + if (status === 'idle') { + return ( + } + title={t.contact.noneSelected} + > + + + + {t.readerHint} + + + ) + } + + return ( + +
+ +
+ setScrolledId(y > 0 && card ? card.id : null)} + > + {status === 'pending' && } + {status === 'error' && + (problem === 'notFound' ? ( + } title={t.contact.gone} /> + ) : ( + + ))} + {status === 'success' && card && } + +
+ ) +} + +function Toolbar({ + card, + isMobile, + vcfUrl, + onBack, + onWrite, + onEdit, + onDelete, + onShowMessages, +}: ContactDetailProps) { + const { t } = useI18n() + const email = card?.emails[0]?.value + const size = isMobile ? 44 : 'lg' + const iconSize = isMobile ? 21 : 18 + const stroke = isMobile ? 1.75 : 2 + + const more = ( + + + + {isMobile ? ( + + ) : ( + + )} + + + + }> + {t.contact.downloadCard} + + {email && ( + } onClick={() => onShowMessages(email)}> + {t.contact.showMessages} + + )} + {onDelete && ( + <> + + } + rightSection={} + onClick={onDelete} + > + {t.contact.delete} + + + )} + + + ) + + return ( + + {isMobile && onBack && ( + + + + )} + {isMobile && } + {onWrite && ( + + email && onWrite(email)} + > + + + + )} + {onEdit && ( + + + + + + )} + {more} + + ) +} + +/** One line of the card: its kind or type on the left, the value, a copy button on hover. */ +function Field({ label, copy, children }: { label: string; copy?: string; children: ReactNode }) { + const { t } = useI18n() + return ( +
+ + {label} + +
{children}
+ {copy && ( + + {({ copied, copy: run }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + )} +
+ ) +} + +function Body({ + card, + isMobile, + photoUrl, + bookName, + onWrite, +}: ContactDetailProps & { card: ContactCard }) { + const { t, locale } = useI18n() + const typeLabel = (type: ContactType, label?: string) => label || t.contact.types[type] + const meta = [card.org !== card.name ? card.org : '', card.title].filter(Boolean).join(' · ') + const avatar = isMobile ? 64 : 72 + + const address = (a: ContactCard['addresses'][number]) => + [a.street, [a.postalCode, a.city].filter(Boolean).join(' '), a.region, a.country] + .filter((line) => line.trim()) + .join('\n') + + const groups: ReactNode[] = [] + if (card.emails.length) + groups.push( + card.emails.map((e) => ( + + {onWrite ? ( + onWrite(e.value)}> + {e.value} + + ) : ( + {e.value} + )} + + )), + ) + if (card.phones.length) + groups.push( + card.phones.map((p) => ( + + + {p.value} + + + )), + ) + if (card.addresses.length) + groups.push( + card.addresses.map((a) => ( + + + {address(a)} + + + )), + ) + if (card.urls.length) + groups.push( + card.urls.map((u) => ( + + {isWebLink(u.value) ? ( + + {u.value} + + ) : ( + + {u.value} + + )} + + )), + ) + if (card.birthday) + groups.push( + + {formatBirthday(card.birthday, locale)} + , + ) + if (card.note) + groups.push( + + + {card.note} + + , + ) + + return ( + +
+ + {initials({ name: card.name, email: card.emails[0]?.value ?? '' })} + + + + {card.name} + + {meta && ( + + {meta} + + )} + {bookName && ( + + {bookName} + + )} + +
+ {groups.length > 0 && ( + + {groups.map((group, i) => ( +
+ {group} +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/contacts/ContactList.module.css b/frontend/src/components/contacts/ContactList.module.css new file mode 100644 index 0000000..49a44c4 --- /dev/null +++ b/frontend/src/components/contacts/ContactList.module.css @@ -0,0 +1,111 @@ +.root { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; +} + +/* The header floats over the list, as in the message list; rows show faintly through it */ +.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; + pointer-events: none; + + & > * { + pointer-events: auto; + } +} + +.header { + position: relative; + margin: 6px 6px 0; + border-radius: var(--mantine-radius-md); + backdrop-filter: blur(14px) saturate(140%); + transition: box-shadow 150ms ease; + + @mixin light { + background: color-mix( + in srgb, + color-mix(in srgb, var(--mantine-color-gray-1) 55%, var(--mantine-color-body)) 78%, + transparent + ); + box-shadow: + 0 1px 2px rgb(31 29 26 / 6%), + 0 2px 8px -2px rgb(31 29 26 / 10%); + } + + @mixin dark { + background: color-mix( + in srgb, + color-mix(in srgb, var(--mantine-color-dark-8) 55%, var(--mantine-color-body)) 78%, + transparent + ); + box-shadow: + 0 1px 2px rgb(0 0 0 / 30%), + 0 2px 8px -2px rgb(0 0 0 / 40%); + } + + &[data-scrolled] { + @mixin light { + box-shadow: + 0 1px 2px rgb(31 29 26 / 8%), + 0 6px 14px -6px rgb(31 29 26 / 22%); + } + + @mixin dark { + box-shadow: + 0 1px 2px rgb(0 0 0 / 35%), + 0 6px 16px -6px rgb(0 0 0 / 60%); + } + } +} + +/* The filter sits on the translucent bar: a solid field so the text stays crisp */ +.filter { + background: var(--mantine-color-body); +} + +.picker { + flex: none; + max-width: 45%; + font-weight: 500; +} + +.pickerLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stale { + margin-top: -2px; +} + +.viewport { + padding-inline-end: 8px; + mask-image: linear-gradient(to bottom, transparent 4px, #000 var(--fade-end)); + + &:focus-visible { + outline: none; + } +} + +/* A letter heading: lined up with the names, on the page colour so rows pass under it when pinned */ +.letter { + left: 0; + right: 0; + z-index: 1; + display: flex; + align-items: flex-end; + padding: 0 0 6px 16px; + font-size: var(--mantine-font-size-xs); + font-weight: 700; + letter-spacing: 0.04em; + color: var(--mantine-color-dimmed); + background: var(--mantine-color-body); + pointer-events: none; +} diff --git a/frontend/src/components/contacts/ContactList.tsx b/frontend/src/components/contacts/ContactList.tsx new file mode 100644 index 0000000..e316d72 --- /dev/null +++ b/frontend/src/components/contacts/ContactList.tsx @@ -0,0 +1,476 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type KeyboardEvent, + type ReactNode, + type Ref, +} from 'react' +import { + ActionIcon, + Box, + Button, + CloseButton, + Group, + Kbd, + Loader, + Menu, + ScrollArea, + Text, + TextInput, + Title, + Tooltip, +} from '@mantine/core' +import { useElementSize } from '@mantine/hooks' +import { defaultRangeExtractor, useVirtualizer, type Range } from '@tanstack/react-virtual' +import { + ArrowLeftIcon, + BookUserIcon, + CheckIcon, + ChevronDownIcon, + CloudOffIcon, + DownloadIcon, + EllipsisIcon, + RefreshCwIcon, + SearchIcon, + SearchXIcon, + UploadIcon, + UserPlusIcon, +} from 'lucide-react' +import type { AddressBook, ContactSummary, Density } from '../../types' +import { withLetters } from '../../lib/addressBook' +import { useI18n } from '../../lib/i18n' +import { ErrorState, ListSkeleton, StateMessage } from '../States' +import { ContactRow, CONTACT_ROW_HEIGHT } from './ContactRow' +import classes from './ContactList.module.css' + +const LETTER_HEIGHT = 30 + +export type ContactListProps = { + /** The cards to show: already narrowed to the picked book and the filter. */ + cards: ContactSummary[] + /** How many cards the picked book holds, before the filter. */ + total: number + status: 'pending' | 'error' | 'success' + onRetry: () => void + /** The backend does not answer at all. */ + unreachable?: boolean + /** The address book could not be reached: this is the last copy. */ + stale: boolean + books: AddressBook[] + /** A book's id, or `all`. */ + bookId: string + onBookChange: (bookId: string) => void + filter: string + onFilterChange: (value: string) => void + filterRef?: Ref + activeId: string | null + density: Density + isMobile: boolean + photoUrl: (card: ContactSummary) => string | undefined + downloadUrl: string + /** Omit when nothing can be written (the backend is away). */ + onNew?: () => void + onImport?: () => void + onRefresh: () => Promise + /** Click, or arrows in the list */ + onActivate: (id: string) => void + /** Enter, or a click on a phone */ + onOpen: (id: string) => void + /** Phones: back to mail */ + onBack?: () => void +} + +export function ContactList(props: ContactListProps) { + const { + cards, + total, + status, + onRetry, + unreachable, + stale, + books, + bookId, + onBookChange, + filter, + onFilterChange, + filterRef, + activeId, + density, + isMobile, + photoUrl, + downloadUrl, + onNew, + onImport, + onRefresh, + onActivate, + onOpen, + onBack, + } = props + const { t, locale } = useI18n() + const viewportRef = useRef(null) + const [scrolled, setScrolled] = useState(false) + const [refreshing, setRefreshing] = useState(false) + // The header floats over the list: rows start below it and scroll under it + const { ref: overlayRef, height: overlayHeight } = useElementSize() + const { ref: areaRef, height: areaHeight } = useElementSize() + const topInset = overlayHeight + const rowHeight = CONTACT_ROW_HEIGHT[density] + + const entries = useMemo(() => withLetters(cards), [cards]) + const letterIndexes = useMemo( + () => entries.flatMap((entry, i) => (entry.kind === 'letter' ? [i] : [])), + [entries], + ) + // Where each entry starts: every size is known, so no measuring is needed + const starts = useMemo(() => { + let at = topInset + 6 + return entries.map((entry) => { + const start = at + at += entry.kind === 'letter' ? LETTER_HEIGHT : rowHeight + return start + }) + }, [entries, rowHeight, topInset]) + // The heading of the section just under the floating header stays pinned there + const pinned = useRef(0) + const rangeExtractor = useCallback( + (range: Range) => { + const top = (viewportRef.current?.scrollTop ?? 0) + topInset + 6 + pinned.current = [...letterIndexes].reverse().find((i) => (starts[i] ?? 0) <= top) ?? 0 + return [...new Set([pinned.current, ...defaultRangeExtractor(range)])].sort((a, b) => a - b) + }, + [letterIndexes, starts, topInset], + ) + + const virtualizer = useVirtualizer({ + count: entries.length, + getScrollElement: () => viewportRef.current, + estimateSize: (i) => (entries[i]?.kind === 'letter' ? LETTER_HEIGHT : rowHeight), + overscan: 10, + paddingStart: topInset + 6, + paddingEnd: 12, + rangeExtractor, + }) + + useEffect(() => { + virtualizer.measure() + }, [rowHeight, entries, virtualizer]) + + // Keep the keyboard cursor in view (and rendered, so aria-activedescendant resolves) + const activeEntry = + activeId === null ? -1 : entries.findIndex((e) => e.kind === 'card' && e.card.id === activeId) + useEffect(() => { + if (activeEntry >= 0) virtualizer.scrollToIndex(activeEntry, { align: 'auto' }) + }, [activeEntry, virtualizer]) + + const activeIndex = activeId === null ? -1 : cards.findIndex((c) => c.id === activeId) + const onListKeyDown = (e: KeyboardEvent) => { + if (!cards.length) return + const go = (i: number) => { + e.preventDefault() + const card = cards[Math.min(Math.max(i, 0), cards.length - 1)] + if (card) onActivate(card.id) + } + switch (e.key) { + case 'ArrowDown': + return go(activeIndex < 0 ? 0 : activeIndex + 1) + case 'ArrowUp': + return go(Math.max(activeIndex, 0) - 1) + case 'Home': + return go(0) + case 'End': + return go(cards.length - 1) + case 'Enter': + if (activeId !== null) { + e.preventDefault() + onOpen(activeId) + } + return + } + } + + const refresh = () => { + setRefreshing(true) + void onRefresh().finally(() => setRefreshing(false)) + } + + const several = books.length > 1 + const bookName = + bookId === 'all' ? t.contact.allBooks : (books.find((b) => b.id === bookId)?.name ?? '') + const count = new Intl.NumberFormat(locale).format(total) + + const bookPicker = several && ( + + + + + + {t.contact.bookPicker} + {[{ id: 'all', name: t.contact.allBooks }, ...books].map((book) => ( + onBookChange(book.id)} + rightSection={book.id === bookId ? : null} + > + {book.name} + + ))} + + + ) + + const moreMenu = ( + + + + + + + + {onImport && ( + } onClick={onImport}> + {t.contact.import} + + )} + } + disabled={status !== 'success'} + > + {several && bookId === 'all' ? t.contact.downloadAll : t.contact.downloadBook} + + + : } + onClick={refresh} + closeMenuOnClick={false} + disabled={refreshing} + > + {refreshing ? t.refreshing : t.refresh} + + + + ) + + const header = ( + + + {onBack && ( + + + + )} + + + {t.contact.title} + + {status === 'success' && ( + + {count} + + )} + + {onNew && ( + + + + + + )} + {moreMenu} + + + onFilterChange(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Escape' && filter) { + e.stopPropagation() + onFilterChange('') + } + // Down from the field goes to the list, like a search box with results + if (e.key === 'ArrowDown' && cards[0]) { + e.preventDefault() + viewportRef.current?.focus() + if (activeIndex < 0) onActivate(cards[0].id) + } + }} + leftSection={} + rightSection={ + filter ? ( + onFilterChange('')} + /> + ) : isMobile ? null : ( + / + ) + } + /> + {bookPicker} + + {stale && ( + + + + {t.contact.stale} + + + )} + + ) + + // Below the floating header, centred in what is left of the pane + const state = (node: ReactNode) => ( + + {node} + + ) + let body: ReactNode + if (status === 'pending') body = state() + else if (status === 'error') + body = state( + , + ) + else if (!cards.length) + body = state( + filter.trim() ? ( + } + title={t.contact.noMatch} + /> + ) : ( + } title={t.contact.empty}> + {t.contact.emptyHint} + {onNew && ( + + )} + + ), + ) + else + body = ( +
+ {virtualizer.getVirtualItems().map((vi) => { + const entry = entries[vi.index] + if (!entry) return null + if (entry.kind === 'letter') { + const isPinned = vi.index === pinned.current + return ( +
+ {entry.letter} +
+ ) + } + const { card } = entry + return ( + + ) + })} +
+ ) + + return ( +
+
+ {header} +
+ setScrolled(y > 0)} + viewportProps={{ + role: 'listbox', + tabIndex: 0, + 'aria-label': t.contact.title, + 'aria-activedescendant': + activeId !== null && activeIndex >= 0 ? `contact-${activeId}` : undefined, + 'aria-busy': status === 'pending', + onKeyDown: onListKeyDown, + }} + > + {body} + +
+ ) +} diff --git a/frontend/src/components/contacts/ContactRow.module.css b/frontend/src/components/contacts/ContactRow.module.css new file mode 100644 index 0000000..f22ff7f --- /dev/null +++ b/frontend/src/components/contacts/ContactRow.module.css @@ -0,0 +1,63 @@ +.slot { + position: relative; +} + +/* The same rounded highlight as a message row, inset in its slot */ +.row { + display: flex; + align-items: center; + gap: 12px; + height: calc(100% - 4px); + margin: 2px 6px; + padding-inline: 10px 8px; + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-body); + cursor: pointer; + user-select: none; + + [data-density='compact'] & { + gap: 10px; + } + + @mixin hover { + background: var(--mantine-color-default-hover); + } + + &[data-active] { + background: color-mix( + in srgb, + var(--mantine-primary-color-filled) 7%, + var(--mantine-color-body) + ); + } +} + +/* Keyboard cursor: visible only while the list has focus, as in the message list */ +:global([role='listbox']:focus-visible) .row[data-active] { + outline: 1.5px solid color-mix(in srgb, var(--mantine-primary-color-filled) 55%, transparent); + outline-offset: -1.5px; +} + +.text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.name { + display: block; + line-height: 20px; + font-weight: 500; +} + +.detail { + display: block; + line-height: 18px; + + /* Compact rows keep one line, like compact message rows drop the snippet */ + [data-density='compact'] & { + display: none; + } +} diff --git a/frontend/src/components/contacts/ContactRow.tsx b/frontend/src/components/contacts/ContactRow.tsx new file mode 100644 index 0000000..e21e5a7 --- /dev/null +++ b/frontend/src/components/contacts/ContactRow.tsx @@ -0,0 +1,69 @@ +import { memo, type CSSProperties } from 'react' +import { Avatar, Text } from '@mantine/core' +import type { ContactSummary, Density } from '../../types' +import { secondLine } from '../../lib/addressBook' +import { initials } from '../../lib/format' +import classes from './ContactRow.module.css' + +// Slot per row, the highlight inset 2px top and bottom like the message rows +export const CONTACT_ROW_HEIGHT: Record = { comfortable: 60, compact: 46 } +export const AVATAR_SIZE: Record = { comfortable: 36, compact: 28 } + +export type ContactRowProps = { + card: ContactSummary + domId: string + active: boolean + density: Density + photoUrl?: string + style?: CSSProperties + onActivate: (id: string) => void +} + +function ContactRowImpl({ + card, + domId, + active, + density, + photoUrl, + style, + onActivate, +}: ContactRowProps) { + const detail = secondLine(card) + const size = AVATAR_SIZE[density] + return ( +
+
onActivate(card.id)} + > + + {initials({ name: card.name, email: card.emails[0] ?? '' })} + +
+ + {card.name} + + {detail && ( + + {detail} + + )} +
+
+
+ ) +} + +export const ContactRow = memo(ContactRowImpl) diff --git a/frontend/src/hooks.ts b/frontend/src/hooks.ts index a518241..5c52666 100644 --- a/frontend/src/hooks.ts +++ b/frontend/src/hooks.ts @@ -1,7 +1,10 @@ import { createContext, use } from 'react' import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query' import type { + AddressBookData, Contact, + ContactCard, + ContactInput, DraftInput, Folder, Message, @@ -37,7 +40,54 @@ export interface MailActions { refresh(folder: string): Promise } -export interface MailHooks { +/** + * Why a write to the address book failed: + * - `changed`: someone changed the card since it was read (reload it, keep the user's input) + * - `unavailable`: the address book's server cannot be reached + * - `notFound`: the card is gone + * - `failed`: anything else, the backend itself included + */ +export type ContactProblem = 'changed' | 'unavailable' | 'notFound' | 'failed' + +export class ContactError extends Error { + readonly problem: ContactProblem + + constructor(problem: ContactProblem) { + super(problem) + this.problem = problem + } +} + +/** Every write takes the ETag the user saw, so it never overwrites a change made elsewhere. */ +export interface ContactActions { + create(book: string, input: ContactInput): Promise + update(id: string, etag: string, input: ContactInput): Promise + /** Undo puts the card back as it was, under a new id. */ + remove(card: { id: string; book: string; etag: string }): Promise + setPhoto(id: string, etag: string, jpeg: Blob): Promise + removePhoto(id: string, etag: string): Promise + importFile(book: string, file: File): Promise<{ imported: number; failed: number }> + /** Asks the backend to sync with the address book now; the list follows if anything changed. */ + refresh(): Promise<{ available: boolean; changed: boolean }> +} + +export interface ContactsHooks { + /** Every card, from the backend's copy; idle when `enabled` is false. */ + useAddressBook(enabled: boolean): UseQueryResult + /** The whole card; idle when `id` is null. Fails with a ContactError. */ + useContactCard(id: string | null): UseQueryResult + useContactActions(): ContactActions +} + +/** Where the browser finds pictures and downloads. */ +export type ContactUrls = { + photo: (card: { id: string; etag: string }) => string + cardVcf: (id: string) => string + bookVcf: (bookId: string) => string + allVcf: () => string +} + +export interface MailHooks extends ContactsHooks { useFolders(): UseQueryResult /** 50 rows per page. `search` null = plain folder listing. */ useMessages(args: { @@ -54,7 +104,13 @@ export interface MailHooks { useContactNames(emails: string[]): ReadonlyMap useSettings(): { settings: Settings; update: (patch: Partial) => void } useRecentSearches(): { recent: string[]; push: (q: string) => void } - useSession(): { name: string; email: string; signOut: () => void } + /** `contacts`: an address book is configured; undefined until the session is known. */ + useSession(): { + name: string + email: string + contacts: boolean | undefined + signOut: () => void + } useMailActions(): MailActions /** False while the backend does not answer at all: nothing that needs it should be offered. */ useServerReachable(): boolean diff --git a/frontend/src/lib/addressBook.test.ts b/frontend/src/lib/addressBook.test.ts new file mode 100644 index 0000000..4c0e892 --- /dev/null +++ b/frontend/src/lib/addressBook.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' + +import type { ContactSummary } from '../types' +import { + formatBirthday, + letterOf, + matchesFilter, + secondLine, + sortCards, + withLetters, +} from './addressBook' + +const card = (patch: Partial): ContactSummary => ({ + id: patch.name ?? 'x', + book: 'b', + etag: '"1"', + name: '', + org: '', + emails: [], + phones: [], + photo: false, + ...patch, +}) + +const emile = card({ + name: 'Émile Durand', + org: 'Société Générale', + emails: ['emile@example.test'], + phones: ['+33 6 12 34 56 78'], +}) + +describe('contact filter', () => { + it('matches everything when empty', () => { + expect(matchesFilter(emile, ' ')).toBe(true) + }) + + it('ignores accents and case, both ways', () => { + expect(matchesFilter(emile, 'emile')).toBe(true) + expect(matchesFilter(emile, 'ÉMILE')).toBe(true) + expect(matchesFilter(card({ name: 'Emile' }), 'émile')).toBe(true) + }) + + it('needs every word, anywhere', () => { + expect(matchesFilter(emile, 'durand generale')).toBe(true) + expect(matchesFilter(emile, 'durand martin')).toBe(false) + }) + + it('looks in emails', () => { + expect(matchesFilter(emile, 'example.test')).toBe(true) + }) + + it('finds phones whatever the spacing', () => { + expect(matchesFilter(emile, '0612')).toBe(false) + expect(matchesFilter(emile, '612 34')).toBe(true) + expect(matchesFilter(emile, '+33612345678')).toBe(true) + }) + + it('does not match a phone on a word without digits', () => { + expect(matchesFilter(card({ phones: ['555'] }), 'abc')).toBe(false) + }) +}) + +describe('contact order', () => { + it('sorts by name, accents and case ignored, other characters last', () => { + const names = sortCards( + ['zoé', 'Émile', '42 Club', 'adam', 'Ölaf', 'Беляев', 'Eve'].map((name) => card({ name })), + ).map((c) => c.name) + expect(names).toEqual(['adam', 'Émile', 'Eve', 'Ölaf', 'zoé', '42 Club', 'Беляев']) + }) + + it('files names under their first letter', () => { + expect(letterOf('Émile')).toBe('E') + expect(letterOf(' zoé')).toBe('Z') + expect(letterOf('42 Club')).toBe('#') + expect(letterOf('')).toBe('#') + }) + + it('puts one heading before each letter', () => { + const rows = withLetters( + sortCards(['Bob', 'alice', 'Anna', '1st'].map((name) => card({ name }))), + ) + expect(rows.map((r) => (r.kind === 'letter' ? r.letter : r.card.name))).toEqual([ + 'A', + 'alice', + 'Anna', + 'B', + 'Bob', + '#', + '1st', + ]) + }) +}) + +describe('second line', () => { + it('prefers the company, then an email, then a phone', () => { + expect(secondLine(emile)).toBe('Société Générale') + expect(secondLine(card({ name: 'A', emails: ['a@x.test'], phones: ['1'] }))).toBe('a@x.test') + expect(secondLine(card({ name: 'A', phones: ['1'] }))).toBe('1') + expect(secondLine(card({ name: 'A' }))).toBe('') + }) + + it('does not repeat what the name already says', () => { + expect(secondLine(card({ name: 'Acme', org: 'Acme', emails: ['hi@acme.test'] }))).toBe( + 'hi@acme.test', + ) + }) +}) + +describe('birthday', () => { + it('shows a full date', () => { + expect(formatBirthday('1984-03-12', 'en-GB')).toBe('12 March 1984') + }) + + it('leaves the year out when the card does not know it', () => { + expect(formatBirthday('--03-12', 'en-GB')).toBe('12 March') + expect(formatBirthday('--0312', 'fr-FR')).toBe('12 mars') + }) +}) diff --git a/frontend/src/lib/addressBook.ts b/frontend/src/lib/addressBook.ts new file mode 100644 index 0000000..7ba3777 --- /dev/null +++ b/frontend/src/lib/addressBook.ts @@ -0,0 +1,88 @@ +import type { ContactSummary } from '../types' + +/** Lower case, without accents: "Émile" and "emile" are the same to the filter and the sort. */ +export function fold(value: string): string { + return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase() +} + +const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true }) + +/** By name, accents and case ignored; names that do not start with a letter go last, under #. */ +export function sortCards(cards: T[]): T[] { + const keyed = cards.map((card) => ({ + card, + other: letterOf(card.name) === '#', + key: fold(card.name.trim()), + })) + keyed.sort((a, b) => Number(a.other) - Number(b.other) || collator.compare(a.key, b.key)) + return keyed.map((k) => k.card) +} + +/** Digits only, so "+33 6 12" finds "+33612…" and the other way round. */ +const digits = (value: string) => value.replace(/\D/g, '') + +/** + * Every word of the query must appear somewhere: in the name, the organisation, an email, + * or, for a word with digits, a phone number. + */ +export function matchesFilter(card: ContactSummary, query: string): boolean { + const words = fold(query).split(/\s+/).filter(Boolean) + if (!words.length) return true + const text = fold([card.name, card.org, ...card.emails].join('\n')) + const phones = card.phones.map(digits) + return words.every((word) => { + if (text.includes(word)) return true + const number = digits(word) + return number.length > 0 && phones.some((phone) => phone.includes(number)) + }) +} + +/** The letter a card is filed under: A to Z, or # for anything else. */ +export function letterOf(name: string): string { + const first = fold(name.trim()).charAt(0).toUpperCase() + return first >= 'A' && first <= 'Z' ? first : '#' +} + +export type ListEntry = + { kind: 'letter'; letter: string } | { kind: 'card'; card: ContactSummary; index: number } + +/** The rows of the list: a heading before each new letter, then its cards. `index` counts cards only. */ +export function withLetters(cards: ContactSummary[]): ListEntry[] { + const out: ListEntry[] = [] + let current: string | null = null + cards.forEach((card, index) => { + const letter = letterOf(card.name) + if (letter !== current) { + out.push({ kind: 'letter', letter }) + current = letter + } + out.push({ kind: 'card', card, index }) + }) + return out +} + +/** The dimmed line under a name: the organisation, else the first email, else the first phone. */ +export function secondLine(card: ContactSummary): string { + if (card.org && card.org !== card.name) return card.org + const email = card.emails.find((e) => e !== card.name) + return email ?? card.phones[0] ?? '' +} + +/** A birthday through Intl, without the year when the card does not know it. */ +export function formatBirthday(value: string, locale: string): string { + const full = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (full) { + const [, y, m, d] = full + return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format( + new Date(Number(y), Number(m) - 1, Number(d)), + ) + } + const partial = /^--(\d{2})-?(\d{2})$/.exec(value) + if (partial) { + const [, m, d] = partial + return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long' }).format( + new Date(2000, Number(m) - 1, Number(d)), + ) + } + return value +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 4663e00..ef1333e 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -208,6 +208,101 @@ const en = { version: (v: string) => `Version ${v}`, releaseNotes: 'Release notes', allShortcuts: 'All shortcuts', + // address book + contact: { + title: 'Contacts', + allBooks: 'All address books', + bookPicker: 'Address book', + filter: 'Filter contacts', + filterShort: 'Filter contacts', + clearFilter: 'Clear the filter', + newContact: 'New contact', + import: 'Import a .vcf file…', + downloadBook: 'Download the address book', + downloadAll: 'Download all address books', + empty: 'No contacts yet', + emptyHint: 'Create one, or import a .vcf file.', + noMatch: 'No contact matches', + loadError: 'Couldn’t load your contacts', + stale: 'Can’t reach the address book. This is the last copy.', + noneSelected: 'No contact selected', + openError: 'Couldn’t open this contact', + gone: 'This contact no longer exists', + write: 'Write', + edit: 'Edit', + downloadCard: 'Download .vcf', + showMessages: 'Show messages', + delete: 'Delete contact', + deleted: (name: string) => `${name} deleted`, + copy: (what: string) => `Copy ${what}`, + website: 'website', + birthday: 'birthday', + note: 'note', + types: { home: 'home', work: 'work', cell: 'mobile', other: 'other' }, + // editor + editTitle: 'Edit contact', + firstName: 'First name', + lastName: 'Last name', + org: 'Company', + jobTitle: 'Job title', + email: 'Email', + phone: 'Phone', + address: 'Address', + url: 'Website', + type: 'Type', + addEmail: 'Add an email', + addPhone: 'Add a phone', + addUrl: 'Add a website', + addAddress: 'Add an address', + removeLine: (what: string) => `Remove this ${what.toLowerCase()}`, + street: 'Street', + postalCode: 'Postcode', + city: 'City', + region: 'Region', + country: 'Country', + birthdayLabel: 'Birthday', + noteLabel: 'Note', + noYear: 'Year unknown', + setDate: 'Set a full date', + book: 'Address book', + save: 'Save', + cancel: 'Cancel', + discardTitle: 'Discard your changes?', + discardBody: 'What you changed on this contact will be lost.', + keepEditing: 'Keep editing', + discardChanges: 'Discard', + needName: 'Add a name, a company or an email', + badEmail: 'This doesn’t look like an email address', + changed: + 'This contact was changed somewhere else in the meantime. Your edits are still here: save again to replace that version.', + unreachable: 'Can’t reach the address book. Your edits are still here: try again in a moment.', + saveFailed: 'Couldn’t save. Your edits are still here.', + created: (name: string) => `${name} added to your contacts`, + saved: 'Contact saved', + // picture + picture: 'Picture', + choosePicture: 'Choose a picture', + replacePicture: 'Replace', + removePicture: 'Remove', + dropPicture: 'Drop an image to use it as the picture', + cropTitle: 'Adjust the picture', + cropArea: 'Picture framing', + cropHelp: 'Drag to move, scroll or pinch to zoom. Arrow keys move, + and − zoom.', + zoom: 'Zoom', + apply: 'Apply', + badImage: 'This image can’t be read. Try a JPEG or PNG file.', + // import + importTitle: 'Import contacts', + importInto: 'Into which address book?', + importing: 'Importing…', + imported: (n: number) => + n === 0 ? 'No contact imported' : n === 1 ? '1 contact imported' : `${n} contacts imported`, + importFailed: (n: number) => + n === 1 ? '1 card couldn’t be read.' : `${n} cards couldn’t be read.`, + // from mail + addToContacts: 'Add to contacts', + openContact: (name: string) => `Open the contact card of ${name}`, + }, // shortcuts help shortcutGroups: { move: 'Move around', act: 'Act on a message', app: 'Write and app' }, shortcutLabels: { @@ -216,6 +311,7 @@ const en = { open: 'Open message', close: 'Close reader, dialog or selection', goInbox: 'Go to Inbox', + goContacts: 'Go to Contacts', search: 'Search', palette: 'Search, folders and actions', reply: 'Reply', @@ -225,6 +321,7 @@ const en = { delete: 'Delete', star: 'Star or unstar', unread: 'Mark unread', + editContact: 'Edit, in Contacts', compose: 'Compose', send: 'Send, in the composer', help: 'Show this list', @@ -449,6 +546,98 @@ const fr: Strings = { version: (v: string) => `Version ${v}`, releaseNotes: 'Notes de version', allShortcuts: 'Tous les raccourcis', + contact: { + title: 'Contacts', + allBooks: 'Tous les carnets d’adresses', + bookPicker: 'Carnet d’adresses', + filter: 'Filtrer les contacts', + filterShort: 'Filtrer', + clearFilter: 'Effacer le filtre', + newContact: 'Nouveau contact', + import: 'Importer un fichier .vcf…', + downloadBook: 'Télécharger le carnet d’adresses', + downloadAll: 'Télécharger tous les carnets d’adresses', + empty: 'Aucun contact pour l’instant', + emptyHint: 'Créez-en un, ou importez un fichier .vcf.', + noMatch: 'Aucun contact ne correspond', + loadError: 'Impossible de charger vos contacts', + stale: 'Carnet d’adresses injoignable. Voici la dernière copie.', + noneSelected: 'Aucun contact sélectionné', + openError: 'Impossible d’ouvrir ce contact', + gone: 'Ce contact n’existe plus', + write: 'Écrire', + edit: 'Modifier', + downloadCard: 'Télécharger le .vcf', + showMessages: 'Voir les messages', + delete: 'Supprimer le contact', + deleted: (name) => `${name} supprimé des contacts`, + copy: (what) => `Copier : ${what}`, + website: 'site web', + birthday: 'anniversaire', + note: 'note', + types: { home: 'domicile', work: 'travail', cell: 'mobile', other: 'autre' }, + editTitle: 'Modifier le contact', + firstName: 'Prénom', + lastName: 'Nom', + org: 'Société', + jobTitle: 'Fonction', + email: 'E-mail', + phone: 'Téléphone', + address: 'Adresse', + url: 'Site web', + type: 'Type', + addEmail: 'Ajouter un e-mail', + addPhone: 'Ajouter un téléphone', + addUrl: 'Ajouter un site web', + addAddress: 'Ajouter une adresse', + removeLine: (what) => `Retirer : ${what.toLowerCase()}`, + street: 'Rue', + postalCode: 'Code postal', + city: 'Ville', + region: 'Région', + country: 'Pays', + birthdayLabel: 'Anniversaire', + noteLabel: 'Note', + noYear: 'Année inconnue', + setDate: 'Indiquer une date complète', + book: 'Carnet d’adresses', + save: 'Enregistrer', + cancel: 'Annuler', + discardTitle: 'Abandonner vos modifications ?', + discardBody: 'Ce que vous avez changé sur ce contact sera perdu.', + keepEditing: 'Continuer', + discardChanges: 'Abandonner', + needName: 'Indiquez un nom, une société ou un e-mail', + badEmail: 'Cette adresse e-mail ne semble pas valide', + changed: + 'Ce contact a été modifié ailleurs entre-temps. Vos modifications sont conservées : enregistrez de nouveau pour remplacer cette version.', + unreachable: + 'Carnet d’adresses injoignable. Vos modifications sont conservées : réessayez dans un instant.', + saveFailed: 'Échec de l’enregistrement. Vos modifications sont conservées.', + created: (name) => `${name} ajouté aux contacts`, + saved: 'Contact enregistré', + picture: 'Photo', + choosePicture: 'Choisir une photo', + replacePicture: 'Remplacer', + removePicture: 'Retirer', + dropPicture: 'Déposez une image pour en faire la photo', + cropTitle: 'Cadrer la photo', + cropArea: 'Cadrage de la photo', + cropHelp: + 'Faites glisser pour déplacer, molette ou pincement pour zoomer. Flèches pour déplacer, + et − pour zoomer.', + zoom: 'Zoom', + apply: 'Appliquer', + badImage: 'Impossible de lire cette image. Essayez un fichier JPEG ou PNG.', + importTitle: 'Importer des contacts', + importInto: 'Dans quel carnet d’adresses ?', + importing: 'Importation…', + imported: (n) => + n === 0 ? 'Aucun contact importé' : n === 1 ? '1 contact importé' : `${n} contacts importés`, + importFailed: (n) => + n === 1 ? '1 fiche n’a pas pu être lue.' : `${n} fiches n’ont pas pu être lues.`, + addToContacts: 'Ajouter aux contacts', + openContact: (name) => `Ouvrir la fiche de ${name}`, + }, shortcutGroups: { move: 'Se déplacer', act: 'Agir sur un message', app: 'Écrire et application' }, shortcutLabels: { next: 'Message suivant', @@ -456,6 +645,7 @@ const fr: Strings = { open: 'Ouvrir le message', close: 'Fermer le lecteur, la fenêtre ou la sélection', goInbox: 'Aller à la boîte de réception', + goContacts: 'Aller aux contacts', search: 'Rechercher', palette: 'Recherche, dossiers et actions', reply: 'Répondre', @@ -465,6 +655,7 @@ const fr: Strings = { delete: 'Supprimer', star: 'Suivre ou ne plus suivre', unread: 'Marquer non lu', + editContact: 'Modifier, dans les contacts', compose: 'Écrire', send: 'Envoyer, dans l’éditeur', help: 'Afficher cette liste', diff --git a/frontend/src/lib/shortcuts.ts b/frontend/src/lib/shortcuts.ts index b6490af..ec8e8ac 100644 --- a/frontend/src/lib/shortcuts.ts +++ b/frontend/src/lib/shortcuts.ts @@ -6,6 +6,7 @@ export type ShortcutId = | 'open' | 'close' | 'goInbox' + | 'goContacts' | 'search' | 'palette' | 'reply' @@ -15,6 +16,7 @@ export type ShortcutId = | 'delete' | 'star' | 'unread' + | 'editContact' | 'compose' | 'send' | 'help' @@ -27,6 +29,9 @@ type ShortcutDef = { alt?: string[] } +/** Only shown, and only bound, when an address book is configured. */ +export const CONTACT_SHORTCUTS: ShortcutId[] = ['goContacts', 'editContact'] + /** Single source for the global handler, tooltips, ShortcutsHelp and the cheat sheet. */ export const SHORTCUTS: Record = { next: { keys: ['j'], group: 'move' }, @@ -34,6 +39,7 @@ export const SHORTCUTS: Record = { open: { keys: ['Enter'], group: 'move' }, close: { keys: ['Escape'], group: 'move' }, goInbox: { keys: ['g', 'i'], group: 'move', sequence: true }, + goContacts: { keys: ['g', 'c'], group: 'move', sequence: true }, search: { keys: ['/'], group: 'move' }, palette: { keys: ['mod', 'K'], group: 'move' }, // handled by Spotlight's own `shortcut` reply: { keys: ['r'], group: 'act' }, @@ -43,6 +49,8 @@ export const SHORTCUTS: Record = { delete: { keys: ['Backspace'], alt: ['Delete'], group: 'act' }, star: { keys: ['s'], group: 'act' }, unread: { keys: ['u'], group: 'act' }, + // Same key as archive: each view hands over only the handlers that make sense there + editContact: { keys: ['e'], group: 'act' }, compose: { keys: ['c'], group: 'app' }, send: { keys: ['mod', 'Enter'], group: 'app' }, // handled inside the composer help: { keys: ['?'], group: 'app' }, @@ -96,12 +104,15 @@ export function useMailShortcuts(handlers: Handlers, enabled: boolean) { ) return + const ids = (Object.keys(SHORTCUTS) as ShortcutId[]).filter((id) => handlers[id]) + if (pendingG.current !== null) { window.clearTimeout(pendingG.current) pendingG.current = null - if (key === 'i' && handlers.goInbox) { + const sequence = ids.find((id) => SHORTCUTS[id].sequence && SHORTCUTS[id].keys[1] === key) + if (sequence) { event.preventDefault() - handlers.goInbox() + handlers[sequence]?.() } return } @@ -110,7 +121,7 @@ export function useMailShortcuts(handlers: Handlers, enabled: boolean) { return } - const match = (Object.keys(SHORTCUTS) as ShortcutId[]).find((id) => { + const match = ids.find((id) => { const def = SHORTCUTS[id] return ( (!def.sequence && def.keys.length === 1 && def.keys[0] === key) || diff --git a/frontend/src/lib/url.test.ts b/frontend/src/lib/url.test.ts index 72f45b8..baeca9b 100644 --- a/frontend/src/lib/url.test.ts +++ b/frontend/src/lib/url.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parseLocation, toUrl } from './url' +import { parseLocation, placeOf, toUrl } from './url' const roundTrip = (url: string) => { const [path, query = ''] = url.split('?') @@ -14,6 +14,8 @@ describe('app urls', () => { '/folders/Projects.Webmail', '/folders/R%C3%A9ception%20%26%20co', '/settings', + '/contacts', + '/contacts/3f2a9c0d1e8b4a7f9c6d5e4b3a2f1e0d', '/search?q=invoice', '/search?q=invoice&in=Receipts&unread=1&attachments=1&range=30d', '/search?q=x&range=custom&from=2026-10-01&to=2026-10-20', @@ -27,6 +29,7 @@ describe('app urls', () => { folderId: 'INBOX', uid: null, search: null, + contactId: null, }) }) @@ -34,6 +37,23 @@ describe('app urls', () => { expect(parseLocation('/folders/INBOX/abc', '').uid).toBeNull() }) + it('opens a contact', () => { + expect(parseLocation('/contacts/abc123', '')).toMatchObject({ + view: 'contacts', + contactId: 'abc123', + }) + }) + + it('ignores a contact id that is not one', () => { + expect(parseLocation('/contacts/..%2F..%2Fetc', '').contactId).toBeNull() + }) + + it('opening a message or a contact stays in the same place', () => { + expect(placeOf('/folders/INBOX/12')).toBe(placeOf('/folders/INBOX')) + expect(placeOf('/contacts/abc')).toBe(placeOf('/contacts')) + expect(placeOf('/contacts')).not.toBe(placeOf('/settings')) + }) + it('decodes folder names', () => { expect(parseLocation('/folders/R%C3%A9ception%20%26%20co', '').folderId).toBe('Réception & co') }) diff --git a/frontend/src/lib/url.ts b/frontend/src/lib/url.ts index d1266b9..cd7498e 100644 --- a/frontend/src/lib/url.ts +++ b/frontend/src/lib/url.ts @@ -2,26 +2,42 @@ import type { DatePreset, SearchFilters } from '../types' /** What the address bar says: one place in the app, shareable and restorable. */ export type AppLocation = { - view: 'mail' | 'settings' + view: 'mail' | 'settings' | 'contacts' folderId: string uid: number | null search: SearchFilters | null + /** The open contact, in the contacts view. */ + contactId: string | null } const PRESETS: DatePreset[] = ['any', '7d', '30d', '1y', 'custom'] const segment = (value: string) => encodeURIComponent(value) +// Contact ids are opaque hex from the backend; anything else in the address bar is ignored +const CONTACT_ID = /^[A-Za-z0-9_-]{1,64}$/ + /** - * /folders/[/], /settings, /search?q=…&. + * /folders/[/], /settings, /contacts[/], /search?q=…&. * Anything else lands on the inbox. */ export function parseLocation(pathname: string, query: string): AppLocation { - const inbox: AppLocation = { view: 'mail', folderId: 'INBOX', uid: null, search: null } + const inbox: AppLocation = { + view: 'mail', + folderId: 'INBOX', + uid: null, + search: null, + contactId: null, + } const parts = pathname.split('/').filter(Boolean).map(decodeURIComponent) if (parts[0] === 'settings') return { ...inbox, view: 'settings' } + if (parts[0] === 'contacts') { + const id = parts[1] ?? '' + return { ...inbox, view: 'contacts', contactId: CONTACT_ID.test(id) ? id : null } + } + if (parts[0] === 'search') { const params = new URLSearchParams(query) const preset = params.get('range') as DatePreset | null @@ -54,6 +70,9 @@ export function parseLocation(pathname: string, query: string): AppLocation { export function toUrl(location: AppLocation): string { if (location.view === 'settings') return '/settings' + if (location.view === 'contacts') + return location.contactId === null ? '/contacts' : `/contacts/${segment(location.contactId)}` + if (location.search) { const { search } = location const params = new URLSearchParams() @@ -73,3 +92,9 @@ export function toUrl(location: AppLocation): string { const folder = `/folders/${segment(location.folderId)}` return location.uid === null ? folder : `${folder}/${location.uid}` } + +/** The part of a URL that makes it a new place in the history; the rest is what is open there. */ +export function placeOf(url: string): string { + const path = url.split('?')[0]!.split('/') + return path.slice(0, path[1] === 'contacts' ? 2 : 3).join('/') +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 582d73f..93416fc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -14,6 +14,7 @@ import { type ReactNode, StrictMode, useEffect } from 'react' import { createRoot } from 'react-dom/client' import { createMailHooks, partUrl } from './api/mailHooks' +import { contactUrls } from './api/contactsHooks' import { MailHooksProvider, useMailHooks } from './hooks' import { MailApp } from './MailApp' import { faviconUrl } from './components/Logo' @@ -62,6 +63,7 @@ createRoot(document.getElementById('root')!).render( ? `${partUrl(ref, a.id, true)}&thumb=1` : partUrl(ref, a.id, disposition === 'inline') } + contactUrls={contactUrls} /> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2d96e93..6660246 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -98,6 +98,71 @@ export type DraftInput = { draftUid?: number } +/* ---------- address book ---------- */ + +export type AddressBook = { id: string; name: string } + +/** One card in the list: enough to show a row and filter on. */ +export type ContactSummary = { + id: string + book: string + etag: string + /** Never empty: the backend falls back to the organisation, then the first email. */ + name: string + org: string + emails: string[] + phones: string[] + photo: boolean +} + +export type AddressBookData = { + /** False when the last attempt to reach the address book failed: this is the last copy. */ + available: boolean + books: AddressBook[] + /** Sorted by name, accents ignored. */ + cards: ContactSummary[] +} + +export type ContactType = 'home' | 'work' | 'cell' | 'other' + +/** + * `key` is the entry's place on the card as the server sent it, so an edit lands on the + * right line. New entries have none. + */ +export type ContactValue = { key?: number; value: string; type: ContactType; label?: string } +export type ContactAddress = { + key?: number + type: ContactType + street: string + city: string + region: string + postalCode: string + country: string +} +export type ContactUrl = { key?: number; value: string } + +export type ContactCard = { + id: string + book: string + etag: string + name: string + firstName: string + lastName: string + org: string + title: string + emails: ContactValue[] + phones: ContactValue[] + addresses: ContactAddress[] + urls: ContactUrl[] + /** `YYYY-MM-DD`, or `--MM-DD` when the year is unknown; empty when none. */ + birthday: string + note: string + photo: boolean +} + +/** What the editor sends: the card without what the server computes. */ +export type ContactInput = Omit + /** Every mutating action resolves to an undo function, so the UI can offer Undo in a toast. */ export type Undo = () => Promise From 5510be83c4ab8e4c46a96aad3d2699d03115e6e0 Mon Sep 17 00:00:00 2001 From: tchapi Date: Thu, 12 Nov 2026 23:26:01 +0100 Subject: [PATCH 02/13] [Contacts] Create, edit and delete contacts, crop pictures, import .vcf files --- frontend/src/ContactsApp.tsx | 317 +++++++- .../src/components/contacts/ContactDetail.tsx | 4 +- .../contacts/ContactEditor.module.css | 130 ++++ .../src/components/contacts/ContactEditor.tsx | 684 ++++++++++++++++++ .../contacts/PhotoCropper.module.css | 43 ++ .../src/components/contacts/PhotoCropper.tsx | 249 +++++++ frontend/src/lib/i18n.ts | 2 + frontend/src/lib/image.test.ts | 78 ++ frontend/src/lib/image.ts | 102 +++ 9 files changed, 1578 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/contacts/ContactEditor.module.css create mode 100644 frontend/src/components/contacts/ContactEditor.tsx create mode 100644 frontend/src/components/contacts/PhotoCropper.module.css create mode 100644 frontend/src/components/contacts/PhotoCropper.tsx create mode 100644 frontend/src/lib/image.test.ts create mode 100644 frontend/src/lib/image.ts diff --git a/frontend/src/ContactsApp.tsx b/frontend/src/ContactsApp.tsx index 43598ba..51216dd 100644 --- a/frontend/src/ContactsApp.tsx +++ b/frontend/src/ContactsApp.tsx @@ -1,13 +1,22 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Button, Group, Modal, Select, Stack, Text } from '@mantine/core' import { useLocalStorage } from '@mantine/hooks' +import { notifications } from '@mantine/notifications' +import { CheckIcon, CircleAlertIcon, UploadIcon } from 'lucide-react' import { ContactError, useMailHooks, type ContactUrls } from './hooks' -import type { Addr, ContactSummary, Density } from './types' +import type { Addr, ContactCard, ContactInput, ContactSummary, Density, Undo } from './types' import { matchesFilter } from './lib/addressBook' +import { useI18n } from './lib/i18n' import { useMailShortcuts } from './lib/shortcuts' import type { Layout } from './components/AppShell' import { SplitPanes } from './components/SplitPanes' import { ContactList } from './components/contacts/ContactList' import { ContactDetail } from './components/contacts/ContactDetail' +import { + ContactEditor, + type ContactSave, + type SaveOutcome, +} from './components/contacts/ContactEditor' export type ContactsAppProps = { layout: Layout @@ -29,8 +38,13 @@ export type ContactsAppProps = { reachable: boolean } +type Editing = + { kind: 'edit'; id: string } | { kind: 'new'; key: number; prefill?: Partial } + +const problemOf = (error: unknown) => (error instanceof ContactError ? error.problem : 'failed') + /** - * The address book: the list of cards on the left, the open card on the right. + * The address book: the list of cards on the left, the open card (or the editor) on the right. * Lazy-loaded with its components; MailApp only knows the view and the open card's id. */ export function ContactsApp({ @@ -47,6 +61,7 @@ export function ContactsApp({ shortcutsEnabled, reachable, }: ContactsAppProps) { + const { t } = useI18n() const hooks = useMailHooks() const actions = hooks.useContactActions() const bookQuery = hooks.useAddressBook(true) @@ -65,6 +80,11 @@ export function ContactsApp({ const [filter, setFilter] = useState('') const filterRef = useRef(null) const [mobileDetail, setMobileDetail] = useState(contactId !== null) + const [editing, setEditing] = useState(null) + const newKey = useRef(0) + const [importBook, setImportBook] = useState(null) + const [askBook, setAskBook] = useState(false) + const fileRef = useRef(null) const data = bookQuery.data const books = useMemo(() => data?.books ?? [], [data]) @@ -75,6 +95,8 @@ export function ContactsApp({ [data, bookId], ) const cards = useMemo(() => inBook.filter((c) => matchesFilter(c, filter)), [inBook, filter]) + // New cards and imports go to the picked book, else the first one + const targetBook = bookId === 'all' ? (books[0]?.id ?? '') : bookId // On open: show the copy at once, and ask the backend to look for changes behind it const refreshed = useRef(false) @@ -88,6 +110,43 @@ export function ContactsApp({ const photoUrl = (c: Pick) => c.photo ? urls.photo(c) : undefined + /* ---------- feedback ---------- */ + const notifyDone = useCallback( + (message: string, undo?: Undo) => { + const id = notifications.show({ + message: ( + + {message} + {undo && ( + + )} + + ), + icon: , + color: 'gray', + autoClose: undo ? 6000 : 3000, + }) + }, + [t], + ) + const notifyProblem = (error: unknown) => + notifications.show({ + color: 'red', + icon: , + message: problemOf(error) === 'unavailable' ? t.contact.unreachableNothing : t.actionFailed, + }) + + /* ---------- navigation ---------- */ const open = (id: string) => { onContactChange(id) if (isMobile) setMobileDetail(true) @@ -105,6 +164,126 @@ export function ContactsApp({ onCompose(email ? { email, name: card?.name } : undefined) } + /* ---------- writes ---------- */ + const canWrite = reachable && books.length > 0 + + const startNew = (prefill?: Partial) => { + newKey.current += 1 + setEditing({ kind: 'new', key: newKey.current, prefill }) + if (isMobile) setMobileDetail(true) + } + + const startEdit = () => { + if (card) setEditing({ kind: 'edit', id: card.id }) + } + + const save = async ({ + book, + input, + fieldsChanged, + photo, + etag, + }: ContactSave): Promise => { + let saved: ContactCard | undefined + try { + // Fields first, then the picture, each with the ETag the previous answer gave + if (editing?.kind === 'new') saved = await actions.create(book, input) + else if (card && fieldsChanged) + saved = await actions.update(card.id, etag ?? card.etag, input) + const target = saved ?? card + if (target && photo) { + const tag = saved?.etag ?? etag ?? target.etag + saved = + photo === 'remove' + ? await actions.removePhoto(target.id, tag) + : await actions.setPhoto(target.id, tag, photo) + } + } catch (error) { + const problem = problemOf(error) + if (editing?.kind === 'new' && saved) { + // The card exists, only its picture failed: open it rather than create it twice + setEditing(null) + open(saved.id) + notifications.show({ + color: 'red', + icon: , + message: t.contact.saveFailed, + }) + return { ok: true } + } + if (problem === 'changed' && card) { + // Someone else's version is the base from now on; the user's input stays in the form + const fresh = await cardQuery.refetch() + return { ok: false, problem, etag: fresh.data?.etag } + } + return { ok: false, problem, etag: saved?.etag } + } + setEditing(null) + if (saved) { + open(saved.id) + notifyDone(editing?.kind === 'new' ? t.contact.created(saved.name) : t.contact.saved) + } + return { ok: true } + } + + const remove = async () => { + if (!card) return + const idx = cards.findIndex((c) => c.id === card.id) + const next = cards[idx + 1] ?? cards[idx - 1] + try { + const undo = await actions.remove(card) + onContactChange(next && next.id !== card.id ? next.id : null) + if (isMobile) setMobileDetail(false) + notifyDone(t.contact.deleted(card.name), undo) + } catch (error) { + notifyProblem(error) + } + } + + const importInto = async (book: string, file: File) => { + const id = notifications.show({ + loading: true, + message: t.contact.importing, + autoClose: false, + withCloseButton: false, + }) + try { + const { imported, failed } = await actions.importFile(book, file) + notifications.update({ + id, + loading: false, + color: failed ? 'yellow' : 'gray', + icon: failed ? : , + message: failed + ? `${t.contact.imported(imported)}. ${t.contact.importFailed(failed)}` + : t.contact.imported(imported), + autoClose: 6000, + withCloseButton: true, + }) + } catch (error) { + notifications.update({ + id, + loading: false, + color: 'red', + icon: , + message: problemOf(error) === 'unavailable' ? t.contact.unreachableNothing : t.actionFailed, + autoClose: 6000, + withCloseButton: true, + }) + } + } + + const chooseImport = () => { + // With several books and none picked, ask which one first + if (books.length > 1 && bookId === 'all') { + setImportBook(books[0]!.id) + setAskBook(true) + } else { + setImportBook(targetBook) + fileRef.current?.click() + } + } + useMailShortcuts( { next: () => step(1), @@ -116,10 +295,13 @@ export function ContactsApp({ }, search: () => filterRef.current?.focus(), compose: () => write(card?.emails[0]?.value), + editContact: canWrite ? startEdit : undefined, + delete: canWrite ? () => void remove() : undefined, goInbox: onGoInbox, help: onShowShortcuts, }, - shortcutsEnabled, + // The editor has its own keys + shortcutsEnabled && editing === null, ) const downloadUrl = bookId === 'all' ? urls.allVcf() : urls.bookVcf(bookId) @@ -145,37 +327,114 @@ export function ContactsApp({ isMobile={isMobile} photoUrl={photoUrl} downloadUrl={downloadUrl} + onNew={canWrite ? () => startNew() : undefined} + onImport={canWrite ? chooseImport : undefined} onRefresh={() => actions.refresh().catch(() => undefined)} - onActivate={onContactChange} - onOpen={open} + onActivate={(id) => { + if (editing === null) onContactChange(id) + }} + onOpen={(id) => { + if (editing === null) open(id) + }} onBack={isMobile ? onClose : undefined} /> ) - const detail = ( - void cardQuery.refetch()} - isMobile={isMobile} - photoUrl={card ? photoUrl(card) : undefined} - vcfUrl={contactId ? urls.cardVcf(contactId) : '#'} - bookName={bookName} - onBack={() => setMobileDetail(false)} - onWrite={onCompose ? write : undefined} - onShowMessages={onShowMessages} - /> - ) + let detail + if (editing && (editing.kind === 'new' || card?.id === editing.id)) + detail = ( + { + setEditing(null) + if (isMobile && editing.kind === 'new') setMobileDetail(false) + }} + /> + ) + else + detail = ( + void cardQuery.refetch()} + isMobile={isMobile} + photoUrl={card ? photoUrl(card) : undefined} + vcfUrl={contactId ? urls.cardVcf(contactId) : '#'} + bookName={bookName} + onBack={() => setMobileDetail(false)} + onWrite={onCompose ? write : undefined} + onShowMessages={onShowMessages} + onEdit={canWrite ? startEdit : undefined} + onDelete={canWrite ? () => void remove() : undefined} + /> + ) + + const showDetail = editing !== null || (mobileDetail && contactId !== null) return ( - + <> + + + { + const file = e.currentTarget.files?.[0] + e.currentTarget.value = '' + if (file && importBook) void importInto(importBook, file) + }} + /> + + setAskBook(false)} + title={t.contact.importTitle} + centered + size="sm" + > + + type && update(i, { type: type as ContactType })} + /> + )} + set({ [kind]: list.filter((_, j) => j !== i) })} + /> + + ))} + set({ [kind]: [...list, { value: '', type: blankType }] })}> + {add} + + + ) + } + + const updateAddress = (i: number, patch: Partial) => + set({ addresses: draft.addresses.map((a, j) => (j === i ? { ...a, ...patch } : a)) }) + + const problemText = + problem === 'changed' + ? t.contact.changed + : problem === 'unavailable' + ? t.contact.unreachable + : problem === 'badImage' + ? t.contact.badImage + : problem + ? t.contact.saveFailed + : null + + const title = card ? t.contact.editTitle : t.contact.newContact + const avatarName = + [draft.firstName, draft.lastName].filter((v) => v.trim()).join(' ') || draft.org + + return ( + +
+ + {isMobile && ( + + + + )} + + {title} + + {!isMobile && ( + + )} + + {t.contact.save} + {keyLabel('mod')} + {keyLabel('Enter')} + + } + disabled={isMobile} + > + + + +
+ + + + {problemText && ( + } + role="alert" + radius="md" + > + {problemText} + + )} + + {!card && books.length > 1 && ( + type && updateAddress(i, { type: type as ContactType })} + /> + set({ addresses: draft.addresses.filter((_, j) => j !== i) })} + /> + +