feat: open new-contact and edit-contact links in the contact form

This commit is contained in:
Linus Rath
2026-10-31 05:16:34 +01:00
parent fe45537037
commit ecefd1cbcc
4 changed files with 130 additions and 9 deletions
@@ -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/<id>/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';/);
});
});
+29 -3
View File
@@ -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/<view>/<date>` link shows.
| { kind: 'calendar'; eventId?: string; view?: CalendarLinkView; date?: string; jmapAccountId?: string; accountId?: string }
| { kind: 'contact'; contactId: string }
// `edit`: `/contacts/<id>/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);
+2 -1
View File
@@ -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[];
};
+48 -5
View File
@@ -245,7 +245,7 @@ export default function ContactFormScreen() {
const route = useRoute<Route>();
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/<id>/edit`) does not know it names a group.
const asGroup = asGroupParam || existing?.kind === 'group';
const existingKeywords = React.useMemo(() => {
const counts = new Map<string, number>();
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<number | null>(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 (
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={handleBack}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{t('contacts.contact', 'Contact')}</Text>
</View>
<View style={styles.missing}>
<Text style={styles.missingText}>{t('contacts.detail.not_found', 'Contact not found')}</Text>
</View>
</SafeAreaView>
);
}
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,