mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +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 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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user