mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
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:
@@ -1,17 +1,23 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing,
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight,
|
||||
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import type { AddressBook } from '../../api/types';
|
||||
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 { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -28,10 +34,12 @@ function isSameCategory(a: ContactCategory, b: ContactCategory): boolean {
|
||||
export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const renameKeyword = useContactsStore((s) => s.renameKeyword);
|
||||
|
||||
const totalCount = React.useMemo(
|
||||
() => contacts.filter((c) => !isGroup(c)).length,
|
||||
@@ -45,7 +53,29 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
})),
|
||||
[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]);
|
||||
// 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 counts = new Map<string, number>();
|
||||
for (const contact of contacts) {
|
||||
@@ -57,12 +87,17 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
.map(([keyword, count]) => ({ keyword, count }))
|
||||
.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
||||
}, [contacts]);
|
||||
const uncategorizedCount = React.useMemo(() => selectUncategorized(contacts).length, [contacts]);
|
||||
|
||||
const [expanded, setExpanded] = React.useState({
|
||||
books: true,
|
||||
shared: true,
|
||||
groups: 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 overlay = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -86,6 +121,34 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
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 (
|
||||
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
@@ -115,61 +178,101 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
expanded={expanded.books}
|
||||
onPress={() => setExpanded((e) => ({ ...e, books: !e.books }))}
|
||||
/>
|
||||
{expanded.books && books.map((book) => (
|
||||
<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' })}
|
||||
/>
|
||||
)}
|
||||
{expanded.books && ownBooks.map(renderBook)}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<SectionHeader
|
||||
label="Groups"
|
||||
expanded={expanded.groups}
|
||||
onPress={() => setExpanded((e) => ({ ...e, groups: !e.groups }))}
|
||||
/>
|
||||
)}
|
||||
{sharedSections.map((section) => (
|
||||
<React.Fragment key={section.accountId}>
|
||||
<SectionHeader
|
||||
label={`Shared from ${section.name}`}
|
||||
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) => (
|
||||
<CategoryRow
|
||||
key={g.id}
|
||||
icon={<Users size={16} color={c.textSecondary} />}
|
||||
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 })}
|
||||
onPress={() => select({ type: 'group', groupId: g.id })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{keywords.length > 0 && (
|
||||
<SectionHeader
|
||||
label="Tags"
|
||||
expanded={expanded.tags}
|
||||
onPress={() => setExpanded((e) => ({ ...e, tags: !e.tags }))}
|
||||
{expanded.groups && (
|
||||
<CategoryRow
|
||||
icon={<Plus size={16} color={c.primary} />}
|
||||
label="New group"
|
||||
count={0}
|
||||
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) => (
|
||||
<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 })}
|
||||
/>
|
||||
renaming === kw.keyword ? (
|
||||
<View key={kw.keyword} style={styles.renameRow}>
|
||||
<Tag size={16} color={c.textSecondary} />
|
||||
<TextInput
|
||||
style={styles.renameInput}
|
||||
value={renameValue}
|
||||
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>
|
||||
</SafeAreaView>
|
||||
</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 styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
@@ -187,25 +297,30 @@ function SectionHeader({ label, expanded, onPress }: { label: string; expanded:
|
||||
) : (
|
||||
<ChevronRight size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.sectionHeaderText}>{label}</Text>
|
||||
{icon}
|
||||
<Text style={styles.sectionHeaderText} numberOfLines={1}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryRow({
|
||||
icon, label, count, active, onPress,
|
||||
icon, label, count, active, onPress, onLongPress, badge, muted,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
onLongPress?: () => void;
|
||||
badge?: string;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
active && styles.rowActive,
|
||||
@@ -213,7 +328,13 @@ function CategoryRow({
|
||||
]}
|
||||
>
|
||||
<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>}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -261,7 +382,7 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
sectionHeaderText: { ...typography.bodySemibold, color: c.text },
|
||||
sectionHeaderText: { ...typography.bodySemibold, color: c.text, flexShrink: 1 },
|
||||
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
@@ -280,6 +401,42 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
rowLabel: { flex: 1, ...typography.body, 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 },
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ import {
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Alert,
|
||||
Share,
|
||||
} 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 { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
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';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
@@ -23,9 +26,11 @@ import {
|
||||
useContactsStore,
|
||||
sortContactsByDisplayName,
|
||||
selectGroupMembers,
|
||||
selectUncategorized,
|
||||
type ContactCategory,
|
||||
} from '../stores/contacts-store';
|
||||
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
||||
import { contactsToVCard } from '../lib/vcard';
|
||||
import {
|
||||
ContactListRow,
|
||||
AddressBookPickerSheet,
|
||||
@@ -50,8 +55,11 @@ function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
const groups: Record<string, ContactCard[]> = {};
|
||||
for (const c of sorted) {
|
||||
const name = getContactDisplayName(c).trim();
|
||||
const letter = (name[0] || '#').toUpperCase();
|
||||
const key = /[A-Z]/.test(letter) ? letter : '#';
|
||||
// Any letter (Ä, É, Ł, 王…) gets its own section like the webmail; only
|
||||
// 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] = [];
|
||||
groups[key].push(c);
|
||||
}
|
||||
@@ -59,7 +67,7 @@ function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
.sort((a, b) => {
|
||||
if (a === '#') return 1;
|
||||
if (b === '#') return -1;
|
||||
return a.localeCompare(b);
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||
})
|
||||
.map((title) => ({ title, data: groups[title] }));
|
||||
}
|
||||
@@ -100,6 +108,7 @@ export default function ContactsScreen() {
|
||||
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
|
||||
const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
@@ -144,9 +153,8 @@ export default function ContactsScreen() {
|
||||
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
|
||||
break;
|
||||
case 'uncategorized':
|
||||
filtered = individuals.filter(
|
||||
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
|
||||
);
|
||||
// "No category" = contacts without a keyword, like the webmail.
|
||||
filtered = selectUncategorized(individuals);
|
||||
break;
|
||||
default:
|
||||
filtered = individuals;
|
||||
@@ -196,12 +204,49 @@ export default function ContactsScreen() {
|
||||
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
|
||||
// the first available book.
|
||||
// the account's default book.
|
||||
const defaultBookId = React.useMemo(() => {
|
||||
if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
|
||||
return addressBooks[0]?.id ?? null;
|
||||
}, [selectedCategory, addressBooks]);
|
||||
return getDefaultAddressBookId();
|
||||
}, [selectedCategory, addressBooks, getDefaultAddressBookId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleBulkMove = async (bookId: string) => {
|
||||
const ids = Array.from(selection);
|
||||
@@ -286,6 +331,20 @@ export default function ContactsScreen() {
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<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 && (
|
||||
<Pressable
|
||||
onPress={() => setMoveSheetOpen(true)}
|
||||
@@ -335,6 +394,7 @@ export default function ContactsScreen() {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
onLongPress={openNewMenu}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user