mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: open new-contact and edit-contact links in the contact form
This commit is contained in:
@@ -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';/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user