diff --git a/src/navigation/__tests__/contact-link.test.ts b/src/navigation/__tests__/contact-link.test.ts new file mode 100644 index 00000000..66b6bead --- /dev/null +++ b/src/navigation/__tests__/contact-link.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleDeepLink, parseDeepLink } from '../linking'; + +// `/contacts/new` opened the Contacts tab with nothing, and `/contacts//edit` +// only the contact (file 10 "deep links new/edit", 06 "Contact deep links"). +// Webmail lib/deep-links.ts parseContactsPath. + +const nav = () => ({ isReady: () => true, navigate: vi.fn() }); +const open = async (url: string) => { + const navigation = nav(); + const link = parseDeepLink(url); + expect(await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true); + return navigation.navigate.mock.calls[0]; +}; + +describe('contact deep links', () => { + it('parses new and edit links, and the older query form', () => { + expect(parseDeepLink('bulwarkmobile://contacts/new')).toEqual({ kind: 'contact-new' }); + expect(parseDeepLink('https://mail.example.com/contacts/new?addEmail=a%40b.co&name=Ada%20Lovelace')) + .toEqual({ kind: 'contact-new', email: 'a@b.co', name: 'Ada Lovelace' }); + expect(parseDeepLink('bulwarkmobile://contacts/new?email=a@b.co&addName=Ada')) + .toEqual({ kind: 'contact-new', email: 'a@b.co', name: 'Ada' }); + expect(parseDeepLink('bulwarkmobile://contacts/C%2F1/edit')).toEqual({ kind: 'contact', contactId: 'C/1', edit: true }); + expect(parseDeepLink('bulwarkmobile://contacts/C1')).toEqual({ kind: 'contact', contactId: 'C1' }); + expect(parseDeepLink('https://mail.example.com/contacts?contactId=C1&view=edit')) + .toEqual({ kind: 'contact', contactId: 'C1', edit: true }); + expect(parseDeepLink('https://mail.example.com/contacts?addEmail=a@b.co')) + .toEqual({ kind: 'contact-new', email: 'a@b.co' }); + expect(parseDeepLink('bulwarkmobile://contacts')).toEqual({ kind: 'contacts' }); + }); + + it('opens the contact form, filled in or on the card', async () => { + expect(await open('bulwarkmobile://contacts/new')).toEqual(['ContactForm', {}]); + expect(await open('bulwarkmobile://contacts/new?addEmail=a@b.co&name=Ada')) + .toEqual(['ContactForm', { prefill: { email: 'a@b.co', name: 'Ada' } }]); + expect(await open('bulwarkmobile://contacts/new?name=Ada')).toEqual(['ContactForm', { prefill: { name: 'Ada' } }]); + expect(await open('bulwarkmobile://contacts/C1/edit')).toEqual(['ContactForm', { contactId: 'C1' }]); + expect(await open('bulwarkmobile://contacts/C1')).toEqual(['ContactDetail', { contactId: 'C1' }]); + }); + + // No RN renderer in the test env: check the form's wiring instead. + it('fills the form from a name-only link and waits for the card of an edit link', () => { + const form = fs.readFileSync(path.resolve(__dirname, '../../screens/ContactFormScreen.tsx'), 'utf8'); + expect(form).toMatch(/if \(prefill\?\.email \|\| prefill\?\.name\) \{/); + expect(form).toMatch(/if \(isEdit && !existing\) return;/); + expect(form).toMatch(/if \(filledFromExisting\.current \|\| !existing\) return;/); + expect(form).toMatch(/const asGroup = asGroupParam \|\| existing\?\.kind === 'group';/); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index b8ec594d..28e4fa5d 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -47,7 +47,10 @@ export type DeepLink = // account it belongs to (`?appAccount=`, from widgets). `view` and `date` // (`YYYY-MM-DD`): the view and day a `/calendar//` link shows. | { kind: 'calendar'; eventId?: string; view?: CalendarLinkView; date?: string; jmapAccountId?: string; accountId?: string } - | { kind: 'contact'; contactId: string } + // `edit`: `/contacts//edit` opens the contact's editor. + | { kind: 'contact'; contactId: string; edit?: true } + // `/contacts/new`, with `?email=`/`?addEmail=` and `?name=`/`?addName=`. + | { kind: 'contact-new'; email?: string; name?: string } | { kind: 'contacts' } | { kind: 'files' } | { kind: 'settings'; tab?: string } @@ -168,7 +171,24 @@ export function parseDeepLink(url: string): DeepLink | null { return { kind: 'calendar', ...(parseCalendarLinkDate(kind) ? { date: kind } : {}) }; } case 'contacts': { - if (kind && kind !== 'new') return { kind: 'contact', contactId: decodeSegment(kind) }; + // Webmail parseContactsPath, with the older query form plugins and + // bookmarks still emit (`?contactId=[&view=edit]`, `?addEmail=`). + const newContact = (email: string | null, name: string | null): DeepLink => ({ + kind: 'contact-new', + ...(email ? { email } : {}), + ...(name ? { name } : {}), + }); + if (kind === 'new') { + return newContact(search.get('email') ?? search.get('addEmail'), search.get('name') ?? search.get('addName')); + } + const contactId = kind ? decodeSegment(kind) : search.get('contactId'); + if (contactId) { + const edit = kind ? value === 'edit' : search.get('view') === 'edit'; + return { kind: 'contact', contactId, ...(edit ? { edit: true as const } : {}) }; + } + if (search.get('addEmail') || search.get('addName')) { + return newContact(search.get('addEmail'), search.get('addName')); + } return { kind: 'contacts' }; } case 'files': @@ -323,7 +343,13 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr navigation.navigate('MainTabs', { screen: 'Calendar' } as never); return true; case 'contact': - navigation.navigate('ContactDetail', { contactId: link.contactId }); + if (link.edit) navigation.navigate('ContactForm', { contactId: link.contactId }); + else navigation.navigate('ContactDetail', { contactId: link.contactId }); + return true; + case 'contact-new': + navigation.navigate('ContactForm', link.email || link.name + ? { prefill: { ...(link.email ? { email: link.email } : {}), ...(link.name ? { name: link.name } : {}) } } + : {}); return true; case 'contacts': navigation.navigate('MainTabs', { screen: 'Contacts' } as never); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 098f7c8f..ac96d074 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -90,7 +90,8 @@ export type RootStackParamList = { contactId?: string; addressBookId?: string; asGroup?: boolean; - prefill?: { email: string; name?: string }; + /** "Add to contacts" and `/contacts/new?email=&name=` links; either may be missing. */ + prefill?: { email?: string; name?: string }; /** Pre-selected member ids when creating a group. */ memberIds?: string[]; }; diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index c669c162..7555cc4a 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -245,7 +245,7 @@ export default function ContactFormScreen() { const route = useRoute(); const t = useLocaleStore((s) => s.t); const opts = React.useMemo(() => formOptions(t), [t]); - const { contactId, addressBookId: initialBook, asGroup, prefill, memberIds: initialMemberIds } = route.params || {}; + const { contactId, addressBookId: initialBook, asGroup: asGroupParam, prefill, memberIds: initialMemberIds } = route.params || {}; const isEdit = !!contactId; const addressBooks = useContactsStore((s) => s.addressBooks); @@ -258,6 +258,8 @@ export default function ContactFormScreen() { () => (contactId ? allContacts.find((c) => c.id === contactId) : undefined), [allContacts, contactId], ); + // An edit link (`/contacts//edit`) does not know it names a group. + const asGroup = asGroupParam || existing?.kind === 'group'; const existingKeywords = React.useMemo(() => { const counts = new Map(); for (const contact of allContacts) { @@ -280,12 +282,14 @@ export default function ContactFormScreen() { const init = blankForm(); init.addressBookId = initialBook || getDefaultAddressBookId() || ''; if (initialMemberIds?.length) init.members = initialMemberIds; - if (prefill?.email) { + if (prefill?.email || prefill?.name) { // "Add sender to contacts": split the display name like the webmail // (first word → given, rest → surname) and never let a mailbox-shaped - // name through (#672). - const mailbox = splitMailbox(prefill.name ? `${prefill.name} <${prefill.email}>` : prefill.email); - init.emails = [{ address: mailbox.email, context: '' }]; + // name through (#672). A `/contacts/new` link may carry only a name. + const mailbox = prefill.email + ? splitMailbox(prefill.name ? `${prefill.name} <${prefill.email}>` : prefill.email) + : { email: '', name: prefill.name ?? '' }; + if (mailbox.email) init.emails = [{ address: mailbox.email, context: '' }]; const parts = (mailbox.name || '').split(/\s+/).filter(Boolean); if (parts.length > 0) { init.given = parts[0]; @@ -310,6 +314,19 @@ export default function ContactFormScreen() { const [datePickerIndex, setDatePickerIndex] = React.useState(null); const [memberPickerOpen, setMemberPickerOpen] = React.useState(false); + // An edit link can open the form before the contacts have loaded: the + // card's fields go in when it arrives, unless the user has typed already. + const filledFromExisting = React.useRef(!!existing); + React.useEffect(() => { + if (filledFromExisting.current || !existing) return; + filledFromExisting.current = true; + if (dirty) return; + const members = asGroup + ? selectGroupMembers({ contacts: allContacts }, existing.id).map((m) => m.id) + : []; + setForm(contactToForm(existing, members)); + }, [existing, dirty, asGroup, allContacts]); + React.useEffect(() => { if (!form.addressBookId) { const fallback = getDefaultAddressBookId(); @@ -344,6 +361,8 @@ export default function ContactFormScreen() { }; const handleSave = async () => { + // Never turn an edit of a card that isn't there into a new card. + if (isEdit && !existing) return; const orgName = form.orgs[0]?.name.trim() || ''; if (asGroup) { if (!form.given.trim()) { @@ -647,6 +666,28 @@ export default function ContactFormScreen() { /> ); + if (isEdit && !existing) { + return ( + + + + + + {t('contacts.contact', 'Contact')} + + + {t('contacts.detail.not_found', 'Contact not found')} + + + ); + } + if (asGroup) { // Groups are a name plus a member list - none of the person fields apply. return ( @@ -1477,6 +1518,8 @@ function formatDateAsISO(d: Date): string { function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, + missing: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + missingText: { ...typography.body, color: c.textMuted }, scrollContent: { paddingVertical: spacing.md, paddingBottom: spacing.xxxl * 2,