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 {
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 },
});
}
+70 -10
View File
@@ -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}
>