feat: bucket the contact index by any letter, add select-all/export, a shared-from drawer section and category rename

This commit is contained in:
Linus Rath
2026-08-29 18:04:47 +02:00
parent 8bf48f6465
commit 358bdb93ea
2 changed files with 277 additions and 60 deletions
+207 -50
View File
@@ -1,17 +1,23 @@
import React from 'react'; import React from 'react';
import { import {
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { import {
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
} from 'lucide-react-native'; } from 'lucide-react-native';
import type { RootStackParamList } from '../../navigation/types';
import type { AddressBook } from '../../api/types';
import type { ContactCategory } from '../../stores/contacts-store'; import type { ContactCategory } from '../../stores/contacts-store';
import { useContactsStore } from '../../stores/contacts-store'; import { useContactsStore, selectGroupMembers, selectUncategorized } from '../../stores/contacts-store';
import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils'; import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
type Nav = NativeStackNavigationProp<RootStackParamList>;
interface Props { interface Props {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
@@ -28,10 +34,12 @@ function isSameCategory(a: ContactCategory, b: ContactCategory): boolean {
export default function ContactsSidebarDrawer({ visible, onClose }: Props) { export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const selectedCategory = useContactsStore((s) => s.selectedCategory); const selectedCategory = useContactsStore((s) => s.selectedCategory);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory); const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const contacts = useContactsStore((s) => s.contacts); const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks); const addressBooks = useContactsStore((s) => s.addressBooks);
const renameKeyword = useContactsStore((s) => s.renameKeyword);
const totalCount = React.useMemo( const totalCount = React.useMemo(
() => contacts.filter((c) => !isGroup(c)).length, () => contacts.filter((c) => !isGroup(c)).length,
@@ -45,7 +53,29 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
})), })),
[addressBooks, contacts], [addressBooks, contacts],
); );
const ownBooks = React.useMemo(() => books.filter((b) => !b.isShared), [books]);
// Books from shared / group accounts, grouped by the account they live in
// (the webmail's "Shared from X" sections).
const sharedSections = React.useMemo(() => {
const byAccount = new Map<string, { name: string; books: Array<AddressBook & { count: number }> }>();
for (const book of books) {
if (!book.isShared) continue;
const key = book.accountId || '?';
const entry = byAccount.get(key) ?? { name: book.accountName || key, books: [] };
entry.books.push(book);
byAccount.set(key, entry);
}
return Array.from(byAccount.entries()).map(([accountId, entry]) => ({ accountId, ...entry }));
}, [books]);
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]); const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
// Count resolved members so a deleted card stops counting immediately.
const memberCountByGroup = React.useMemo(() => {
const out = new Map<string, number>();
for (const g of groups) {
out.set(g.id, selectGroupMembers({ contacts }, g.id).filter((m) => !isGroup(m)).length);
}
return out;
}, [groups, contacts]);
const keywords = React.useMemo(() => { const keywords = React.useMemo(() => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const contact of contacts) { for (const contact of contacts) {
@@ -57,12 +87,17 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
.map(([keyword, count]) => ({ keyword, count })) .map(([keyword, count]) => ({ keyword, count }))
.sort((a, b) => a.keyword.localeCompare(b.keyword)); .sort((a, b) => a.keyword.localeCompare(b.keyword));
}, [contacts]); }, [contacts]);
const uncategorizedCount = React.useMemo(() => selectUncategorized(contacts).length, [contacts]);
const [expanded, setExpanded] = React.useState({ const [expanded, setExpanded] = React.useState({
books: true, books: true,
shared: true,
groups: true, groups: true,
tags: true, tags: true,
}); });
const [renaming, setRenaming] = React.useState<string | null>(null);
const [renameValue, setRenameValue] = React.useState('');
const [renameBusy, setRenameBusy] = React.useState(false);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlay = React.useRef(new Animated.Value(0)).current; const overlay = React.useRef(new Animated.Value(0)).current;
@@ -86,6 +121,34 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
onClose(); onClose();
}; };
const commitRename = async () => {
const from = renaming;
const to = renameValue.trim();
if (!from || !to || renameBusy) { setRenaming(null); return; }
if (to === from) { setRenaming(null); return; }
setRenameBusy(true);
try {
await renameKeyword(from, to);
setRenaming(null);
} catch (err) {
Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error');
} finally {
setRenameBusy(false);
}
};
const renderBook = (book: AddressBook & { count: number }) => (
<CategoryRow
key={book.id}
icon={<BookUser size={16} color={c.textSecondary} />}
label={book.name}
count={book.count}
badge={book.isDefault ? 'Default' : undefined}
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
onPress={() => select({ type: 'addressBook', addressBookId: book.id })}
/>
);
return ( return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}> <Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}> <Animated.View style={[styles.overlay, { opacity: overlay }]}>
@@ -115,61 +178,101 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
expanded={expanded.books} expanded={expanded.books}
onPress={() => setExpanded((e) => ({ ...e, books: !e.books }))} onPress={() => setExpanded((e) => ({ ...e, books: !e.books }))}
/> />
{expanded.books && books.map((book) => ( {expanded.books && ownBooks.map(renderBook)}
<CategoryRow
key={book.id}
icon={<BookUser size={16} color={c.textSecondary} />}
label={book.name}
count={book.count}
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
onPress={() => select({ type: 'addressBook', addressBookId: book.id })}
/>
))}
{expanded.books && (
<CategoryRow
icon={<BookUser size={16} color={c.textMuted} />}
label="Uncategorized"
count={0}
active={selectedCategory.type === 'uncategorized'}
onPress={() => select({ type: 'uncategorized' })}
/>
)}
{groups.length > 0 && ( {sharedSections.map((section) => (
<SectionHeader <React.Fragment key={section.accountId}>
label="Groups" <SectionHeader
expanded={expanded.groups} label={`Shared from ${section.name}`}
onPress={() => setExpanded((e) => ({ ...e, groups: !e.groups }))} icon={<Share2 size={12} color={c.textMuted} />}
/> expanded={expanded.shared}
)} onPress={() => setExpanded((e) => ({ ...e, shared: !e.shared }))}
/>
{expanded.shared && section.books.map(renderBook)}
</React.Fragment>
))}
<SectionHeader
label="Groups"
expanded={expanded.groups}
onPress={() => setExpanded((e) => ({ ...e, groups: !e.groups }))}
/>
{expanded.groups && groups.map((g) => ( {expanded.groups && groups.map((g) => (
<CategoryRow <CategoryRow
key={g.id} key={g.id}
icon={<Users size={16} color={c.textSecondary} />} icon={<Users size={16} color={c.textSecondary} />}
label={getContactDisplayName(g) || 'Group'} label={getContactDisplayName(g) || 'Group'}
count={g.members ? Object.keys(g.members).length : 0} count={memberCountByGroup.get(g.id) ?? 0}
active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })} active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })}
onPress={() => select({ type: 'group', groupId: g.id })} onPress={() => select({ type: 'group', groupId: g.id })}
/> />
))} ))}
{expanded.groups && (
{keywords.length > 0 && ( <CategoryRow
<SectionHeader icon={<Plus size={16} color={c.primary} />}
label="Tags" label="New group"
expanded={expanded.tags} count={0}
onPress={() => setExpanded((e) => ({ ...e, tags: !e.tags }))} active={false}
muted
onPress={() => {
onClose();
navigation.navigate('ContactForm', { asGroup: true });
}}
/> />
)} )}
<SectionHeader
label="Tags"
expanded={expanded.tags}
onPress={() => setExpanded((e) => ({ ...e, tags: !e.tags }))}
/>
{expanded.tags && keywords.map((kw) => ( {expanded.tags && keywords.map((kw) => (
<CategoryRow renaming === kw.keyword ? (
key={kw.keyword} <View key={kw.keyword} style={styles.renameRow}>
icon={<Tag size={16} color={c.textSecondary} />} <Tag size={16} color={c.textSecondary} />
label={kw.keyword} <TextInput
count={kw.count} style={styles.renameInput}
active={isSameCategory(selectedCategory, { type: 'keyword', keyword: kw.keyword })} value={renameValue}
onPress={() => select({ type: 'keyword', keyword: kw.keyword })} onChangeText={setRenameValue}
/> autoFocus
returnKeyType="done"
onSubmitEditing={() => { void commitRename(); }}
placeholderTextColor={c.textMuted}
/>
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.renameBtn}>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => setRenaming(null)} hitSlop={6} style={styles.renameBtn}>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
) : (
<CategoryRow
key={kw.keyword}
icon={<Tag size={16} color={c.textSecondary} />}
label={kw.keyword}
count={kw.count}
active={isSameCategory(selectedCategory, { type: 'keyword', keyword: kw.keyword })}
onPress={() => select({ type: 'keyword', keyword: kw.keyword })}
onLongPress={() => {
setRenameValue(kw.keyword);
setRenaming(kw.keyword);
}}
/>
)
))} ))}
{expanded.tags && (
<CategoryRow
icon={<Tag size={16} color={c.textMuted} />}
label="No category"
count={uncategorizedCount}
active={selectedCategory.type === 'uncategorized'}
onPress={() => select({ type: 'uncategorized' })}
/>
)}
{expanded.tags && keywords.length > 0 && (
<Text style={styles.hint}>Long-press a tag to rename it</Text>
)}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</Animated.View> </Animated.View>
@@ -177,7 +280,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
); );
} }
function SectionHeader({ label, expanded, onPress }: { label: string; expanded: boolean; onPress: () => void }) { function SectionHeader({
label, expanded, onPress, icon,
}: {
label: string;
expanded: boolean;
onPress: () => void;
icon?: React.ReactNode;
}) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
return ( return (
@@ -187,25 +297,30 @@ function SectionHeader({ label, expanded, onPress }: { label: string; expanded:
) : ( ) : (
<ChevronRight size={14} color={c.textMuted} /> <ChevronRight size={14} color={c.textMuted} />
)} )}
<Text style={styles.sectionHeaderText}>{label}</Text> {icon}
<Text style={styles.sectionHeaderText} numberOfLines={1}>{label}</Text>
</Pressable> </Pressable>
); );
} }
function CategoryRow({ function CategoryRow({
icon, label, count, active, onPress, icon, label, count, active, onPress, onLongPress, badge, muted,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
count: number; count: number;
active: boolean; active: boolean;
onPress: () => void; onPress: () => void;
onLongPress?: () => void;
badge?: string;
muted?: boolean;
}) { }) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
onLongPress={onLongPress}
style={({ pressed }) => [ style={({ pressed }) => [
styles.row, styles.row,
active && styles.rowActive, active && styles.rowActive,
@@ -213,7 +328,13 @@ function CategoryRow({
]} ]}
> >
<View style={styles.rowIcon}>{icon}</View> <View style={styles.rowIcon}>{icon}</View>
<Text style={[styles.rowLabel, active && styles.rowLabelActive]} numberOfLines={1}>{label}</Text> <Text
style={[styles.rowLabel, active && styles.rowLabelActive, muted && styles.rowLabelMuted]}
numberOfLines={1}
>
{label}
</Text>
{!!badge && <Text style={styles.rowBadge}>{badge}</Text>}
{count > 0 && <Text style={styles.rowCount}>{count}</Text>} {count > 0 && <Text style={styles.rowCount}>{count}</Text>}
</Pressable> </Pressable>
); );
@@ -261,7 +382,7 @@ function makeStyles(c: ThemePalette) {
paddingTop: spacing.md, paddingTop: spacing.md,
paddingBottom: 4, paddingBottom: 4,
}, },
sectionHeaderText: { ...typography.bodySemibold, color: c.text }, sectionHeaderText: { ...typography.bodySemibold, color: c.text, flexShrink: 1 },
row: { row: {
flexDirection: 'row', flexDirection: 'row',
@@ -280,6 +401,42 @@ function makeStyles(c: ThemePalette) {
}, },
rowLabel: { flex: 1, ...typography.body, color: c.text }, rowLabel: { flex: 1, ...typography.body, color: c.text },
rowLabelActive: { ...typography.bodySemibold, color: c.text }, rowLabelActive: { ...typography.bodySemibold, color: c.text },
rowLabelMuted: { color: c.primary },
rowBadge: {
...typography.small,
color: c.textMuted,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.full,
paddingHorizontal: 6,
paddingVertical: 1,
marginLeft: spacing.sm,
},
rowCount: { ...typography.caption, color: c.textMuted, marginLeft: spacing.sm }, rowCount: { ...typography.caption, color: c.textMuted, marginLeft: spacing.sm },
renameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
minHeight: 44,
paddingHorizontal: spacing.md,
},
renameInput: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight - 6,
paddingHorizontal: spacing.sm,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
renameBtn: {
width: 28, height: 28,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.sm,
},
hint: { ...typography.small, color: c.textMuted, paddingHorizontal: spacing.md, paddingTop: spacing.xs },
}); });
} }
+70 -10
View File
@@ -10,12 +10,15 @@ import {
RefreshControl, RefreshControl,
ScrollView, ScrollView,
Alert, Alert,
Share,
} from 'react-native'; } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { import {
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload, Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload, CheckSquare, Share2,
} from 'lucide-react-native'; } from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types'; import type { ContactCard } from '../api/types';
@@ -23,9 +26,11 @@ import {
useContactsStore, useContactsStore,
sortContactsByDisplayName, sortContactsByDisplayName,
selectGroupMembers, selectGroupMembers,
selectUncategorized,
type ContactCategory, type ContactCategory,
} from '../stores/contacts-store'; } from '../stores/contacts-store';
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils'; import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
import { contactsToVCard } from '../lib/vcard';
import { import {
ContactListRow, ContactListRow,
AddressBookPickerSheet, AddressBookPickerSheet,
@@ -50,8 +55,11 @@ function groupContacts(contacts: ContactCard[]): Section[] {
const groups: Record<string, ContactCard[]> = {}; const groups: Record<string, ContactCard[]> = {};
for (const c of sorted) { for (const c of sorted) {
const name = getContactDisplayName(c).trim(); const name = getContactDisplayName(c).trim();
const letter = (name[0] || '#').toUpperCase(); // Any letter (Ä, É, Ł, 王…) gets its own section like the webmail; only
const key = /[A-Z]/.test(letter) ? letter : '#'; // digits and symbols fall into "#". Hermes supports Unicode property escapes.
const first = Array.from(name)[0] || '#';
const letter = first.toLocaleUpperCase();
const key = /\p{L}/u.test(letter) ? letter : '#';
if (!groups[key]) groups[key] = []; if (!groups[key]) groups[key] = [];
groups[key].push(c); groups[key].push(c);
} }
@@ -59,7 +67,7 @@ function groupContacts(contacts: ContactCard[]): Section[] {
.sort((a, b) => { .sort((a, b) => {
if (a === '#') return 1; if (a === '#') return 1;
if (b === '#') return -1; if (b === '#') return -1;
return a.localeCompare(b); return a.localeCompare(b, undefined, { sensitivity: 'base' });
}) })
.map((title) => ({ title, data: groups[title] })); .map((title) => ({ title, data: groups[title] }));
} }
@@ -100,6 +108,7 @@ export default function ContactsScreen() {
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook); const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts); const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory); const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter); const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
@@ -144,9 +153,8 @@ export default function ContactsScreen() {
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]); filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
break; break;
case 'uncategorized': case 'uncategorized':
filtered = individuals.filter( // "No category" = contacts without a keyword, like the webmail.
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0, filtered = selectUncategorized(individuals);
);
break; break;
default: default:
filtered = individuals; filtered = individuals;
@@ -196,12 +204,49 @@ export default function ContactsScreen() {
await bulkDelete(ids); await bulkDelete(ids);
}; };
const selectAllVisible = () => {
setSelection((prev) => {
const allSelected = visible.length > 0 && visible.every((c) => prev.has(c.id));
return allSelected ? new Set() : new Set(visible.map((c) => c.id));
});
};
const exportSelected = async () => {
const selected = contacts.filter((c) => selection.has(c.id));
if (selected.length === 0) return;
const vcf = contactsToVCard(selected);
try {
const filename = `contacts-${new Date().toISOString().slice(0, 10)}.vcf`;
const path = `${FileSystem.cacheDirectory}${filename}`;
await FileSystem.writeAsStringAsync(path, vcf);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path, {
mimeType: 'text/vcard',
UTI: 'public.vcard',
dialogTitle: 'Export contacts',
});
} else {
await Share.share({ message: vcf });
}
} catch (err) {
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unknown error');
}
};
const openNewMenu = () => {
Alert.alert('Create', undefined, [
{ text: 'New contact', onPress: () => navigation.navigate('ContactForm', {}) },
{ text: 'New group', onPress: () => navigation.navigate('ContactForm', { asGroup: true }) },
{ text: 'Cancel', style: 'cancel' },
]);
};
// Default target for moves/imports: the currently-viewed address book, else // Default target for moves/imports: the currently-viewed address book, else
// the first available book. // the account's default book.
const defaultBookId = React.useMemo(() => { const defaultBookId = React.useMemo(() => {
if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId; if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
return addressBooks[0]?.id ?? null; return getDefaultAddressBookId();
}, [selectedCategory, addressBooks]); }, [selectedCategory, addressBooks, getDefaultAddressBookId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleBulkMove = async (bookId: string) => { const handleBulkMove = async (bookId: string) => {
const ids = Array.from(selection); const ids = Array.from(selection);
@@ -286,6 +331,20 @@ export default function ContactsScreen() {
</Pressable> </Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text> <Text style={styles.headerTitle}>{selection.size} selected</Text>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<Pressable
onPress={selectAllVisible}
style={styles.headerIconBtn}
hitSlop={8}
>
<CheckSquare size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => { void exportSelected(); }}
style={styles.headerIconBtn}
hitSlop={8}
>
<Share2 size={20} color={c.text} />
</Pressable>
{addressBooks.length > 0 && ( {addressBooks.length > 0 && (
<Pressable <Pressable
onPress={() => setMoveSheetOpen(true)} onPress={() => setMoveSheetOpen(true)}
@@ -335,6 +394,7 @@ export default function ContactsScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => navigation.navigate('ContactForm', {})} onPress={() => navigation.navigate('ContactForm', {})}
onLongPress={openNewMenu}
style={styles.addBtn} style={styles.addBtn}
hitSlop={8} hitSlop={8}
> >