feat: filter contacts by company, job title, location, email domain, birthday month and details

This commit is contained in:
Linus Rath
2026-10-31 04:45:41 +01:00
parent 65ffef7108
commit 18c6b03fc1
4 changed files with 352 additions and 4 deletions
@@ -0,0 +1,169 @@
import React from 'react';
import { View, Text, TextInput, Pressable, ScrollView, StyleSheet } from 'react-native';
import { Mail, Phone, Image as ImageIcon, X } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import {
countActiveFilters,
cycleTri,
EMPTY_CONTACT_FILTERS,
type ContactListFilters,
type TriState,
} from '../../lib/contact-list-filters';
interface Props {
filters: ContactListFilters;
onChange: (filters: ContactListFilters) => void;
onClose: () => void;
}
// The contact list's advanced filters (webmail contact-list.tsx): company,
// job title, location, email domain, birthday month and has email / phone /
// photo, each a three-way toggle (off, must have, must not have).
export function ContactFiltersPanel({ filters, onChange, onClose }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const monthNames = React.useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: 'long' });
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
}, [locale]);
const set = <K extends keyof ContactListFilters>(key: K, value: ContactListFilters[K]) =>
onChange({ ...filters, [key]: value });
const field = (key: 'organization' | 'jobTitle' | 'location' | 'emailDomain', label: string, placeholder: string) => (
<View style={styles.field}>
<Text style={styles.label}>{label}</Text>
<TextInput
value={filters[key]}
onChangeText={(v) => set(key, v)}
placeholder={placeholder}
placeholderTextColor={c.textMuted}
accessibilityLabel={label}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
</View>
);
const toggle = (key: 'hasEmail' | 'hasPhone' | 'hasPhoto', label: string, icon: React.ReactNode) => {
const value: TriState = filters[key];
return (
<Pressable
onPress={() => set(key, cycleTri(value))}
style={[styles.chip, value === true && styles.chipOn, value === false && styles.chipNot]}
accessibilityRole="button"
accessibilityState={{ checked: value === null ? false : value === true ? true : 'mixed' }}
>
{icon}
<Text style={[styles.chipText, value === true && styles.chipTextOn, value === false && styles.chipTextNot]}>{label}</Text>
</Pressable>
);
};
return (
<View style={styles.panel}>
<View style={styles.header}>
<Text style={styles.title}>{t('contacts.filters.title', 'Advanced filters')}</Text>
{countActiveFilters(filters) > 0 && (
<Pressable onPress={() => onChange(EMPTY_CONTACT_FILTERS)} hitSlop={6} accessibilityRole="button">
<Text style={styles.clear}>{t('contacts.filters.clear', 'Clear')}</Text>
</Pressable>
)}
<Pressable onPress={onClose} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('contacts.filters.close', 'Close')}>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
<View style={styles.row}>
{field('organization', t('contacts.filters.organization', 'Company'), t('contacts.filters.organization_placeholder', 'e.g. Acme Corp'))}
{field('jobTitle', t('contacts.filters.job_title', 'Job title'), t('contacts.filters.job_title_placeholder', 'e.g. Designer'))}
</View>
<View style={styles.row}>
{field('location', t('contacts.filters.location', 'Location'), t('contacts.filters.location_placeholder', 'City or country'))}
{field('emailDomain', t('contacts.filters.email_domain', 'Email domain'), t('contacts.filters.email_domain_placeholder', 'example.com'))}
</View>
<Text style={styles.label}>{t('contacts.filters.birthday_month', 'Birthday in')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.months}>
<Pressable
onPress={() => set('birthdayMonth', null)}
style={[styles.chip, filters.birthdayMonth === null && styles.chipOn]}
accessibilityRole="radio"
accessibilityState={{ selected: filters.birthdayMonth === null }}
>
<Text style={[styles.chipText, filters.birthdayMonth === null && styles.chipTextOn]}>{t('contacts.filters.any_month', 'Any month')}</Text>
</Pressable>
{monthNames.map((name, i) => {
const active = filters.birthdayMonth === i + 1;
return (
<Pressable
key={name}
onPress={() => set('birthdayMonth', i + 1)}
style={[styles.chip, active && styles.chipOn]}
accessibilityRole="radio"
accessibilityState={{ selected: active }}
>
<Text style={[styles.chipText, active && styles.chipTextOn]}>{name}</Text>
</Pressable>
);
})}
</ScrollView>
<View style={styles.toggles}>
{toggle('hasEmail', t('contacts.filters.has_email', 'Has email'), <Mail size={14} color={c.textMuted} />)}
{toggle('hasPhone', t('contacts.filters.has_phone', 'Has phone'), <Phone size={14} color={c.textMuted} />)}
{toggle('hasPhoto', t('contacts.filters.has_photo', 'Has photo'), <ImageIcon size={14} color={c.textMuted} />)}
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
panel: {
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
padding: spacing.md,
gap: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
header: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
clear: { ...typography.captionMedium, color: c.primary },
row: { flexDirection: 'row', gap: spacing.sm },
field: { flex: 1, gap: 2 },
label: { ...typography.caption, color: c.textMuted },
input: {
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.sm,
paddingVertical: 6,
backgroundColor: c.background,
},
months: { gap: spacing.xs },
toggles: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
chipOn: { backgroundColor: c.primaryBg, borderColor: c.primary },
chipNot: { backgroundColor: c.surface },
chipText: { ...typography.caption, color: c.textMuted },
chipTextOn: { color: c.primary },
chipTextNot: { textDecorationLine: 'line-through' },
});
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import type { ContactCard } from '../../api/types';
import { countActiveFilters, cycleTri, EMPTY_CONTACT_FILTERS, matchesContactFilters } from '../contact-list-filters';
const ana = {
id: 'a', name: { full: 'Ana' },
emails: { e: { address: 'ana@acme.example' } },
organizations: { o: { name: 'Acme Corp', units: [{ name: 'Design' }] } },
titles: { t: { name: 'Designer' } },
addresses: { h: { components: [{ kind: 'locality', value: 'Berlin' }] } },
anniversaries: { b: { kind: 'birth', date: '1990-03-14' } },
} as unknown as ContactCard;
const ben = { id: 'b', name: { full: 'Ben' }, phones: { p: { number: '+49 1' } } } as unknown as ContactCard;
const only = (f: Partial<typeof EMPTY_CONTACT_FILTERS>) => [ana, ben]
.filter((c) => matchesContactFilters(c, { ...EMPTY_CONTACT_FILTERS, ...f }))
.map((c) => c.id);
describe('contact list filters', () => {
it('keeps every contact without filters', () => {
expect(only({})).toEqual(['a', 'b']);
});
it('filters by company or unit, job title, location, email domain and birthday month', () => {
expect(only({ organization: 'design' })).toEqual(['a']);
expect(only({ jobTitle: 'design' })).toEqual(['a']);
expect(only({ location: 'berl' })).toEqual(['a']);
expect(only({ emailDomain: '@acme' })).toEqual(['a']);
expect(only({ birthdayMonth: 3 })).toEqual(['a']);
expect(only({ birthdayMonth: 4 })).toEqual([]);
});
it('filters by having, or not having, an email or a phone', () => {
expect(only({ hasEmail: true })).toEqual(['a']);
expect(only({ hasEmail: false })).toEqual(['b']);
expect(only({ hasPhone: true })).toEqual(['b']);
});
it('counts the filters that are set and cycles a toggle off, on, inverted', () => {
expect(countActiveFilters({ ...EMPTY_CONTACT_FILTERS, location: 'x', hasPhoto: false })).toBe(2);
expect([cycleTri(null), cycleTri(true), cycleTri(false)]).toEqual([true, false, null]);
});
});
+115
View File
@@ -0,0 +1,115 @@
import type { AnniversaryDate, ContactCard } from '../api/types';
import { hasContactPhoto } from './contact-utils';
// The contact list's advanced filters (webmail components/contacts/contact-list.tsx).
export type TriState = boolean | null;
export interface ContactListFilters {
organization: string;
jobTitle: string;
location: string;
emailDomain: string;
birthdayMonth: number | null;
hasEmail: TriState;
hasPhone: TriState;
hasPhoto: TriState;
}
export const EMPTY_CONTACT_FILTERS: ContactListFilters = {
organization: '',
jobTitle: '',
location: '',
emailDomain: '',
birthdayMonth: null,
hasEmail: null,
hasPhone: null,
hasPhoto: null,
};
/** Off, then "must have", then "must not have". */
export function cycleTri(v: TriState): TriState {
return v === null ? true : v === true ? false : null;
}
export function countActiveFilters(f: ContactListFilters): number {
let n = 0;
if (f.organization.trim()) n++;
if (f.jobTitle.trim()) n++;
if (f.location.trim()) n++;
if (f.emailDomain.trim()) n++;
if (f.birthdayMonth !== null) n++;
if (f.hasEmail !== null) n++;
if (f.hasPhone !== null) n++;
if (f.hasPhoto !== null) n++;
return n;
}
function matchTri(actual: boolean, filter: TriState): boolean {
return filter === null || actual === filter;
}
function anniversaryMonth(date: AnniversaryDate): number | null {
if (typeof date === 'string') {
const iso = date.match(/^(\d{4})-(\d{2})/);
if (iso) return parseInt(iso[2], 10);
const partial = date.match(/^--(\d{2})/);
if (partial) return parseInt(partial[1], 10);
return null;
}
if ('month' in date && date.month) return date.month;
if ('utc' in date && date.utc) {
const d = new Date(date.utc);
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
}
return null;
}
/** Whether `contact` passes every filter that is set. */
export function matchesContactFilters(contact: ContactCard, filters: ContactListFilters): boolean {
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
if (!matchTri(hasContactPhoto(contact), filters.hasPhoto)) return false;
const org = filters.organization.trim().toLowerCase();
if (org) {
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
if (!orgs.some((o) => o.name?.toLowerCase().includes(org) || o.units?.some((u) => u.name?.toLowerCase().includes(org)))) {
return false;
}
}
const job = filters.jobTitle.trim().toLowerCase();
if (job) {
const titles = contact.titles ? Object.values(contact.titles) : [];
if (!titles.some((ti) => ti.name?.toLowerCase().includes(job))) return false;
}
const loc = filters.location.trim().toLowerCase();
if (loc) {
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
const match = addresses.some((a) => {
const parts = [a.full, a.fullAddress, a.locality, a.region, a.country, a.postcode, a.street,
...(a.components ?? []).map((comp) => comp.value)];
return parts.some((p) => !!p && p.toLowerCase().includes(loc));
});
if (!match) return false;
}
const domain = filters.emailDomain.trim().toLowerCase().replace(/^@/, '');
if (domain) {
const match = emails.some((e) => {
const at = e.address?.toLowerCase().split('@');
return !!at && at.length > 1 && at[1].includes(domain);
});
if (!match) return false;
}
if (filters.birthdayMonth !== null) {
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
if (!anniversaries.some((a) => a.kind === 'birth' && anniversaryMonth(a.date) === filters.birthdayMonth)) return false;
}
return true;
}
+25 -4
View File
@@ -20,8 +20,12 @@ import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { listBackStep } from '../lib/list-back'; import { listBackStep } from '../lib/list-back';
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, CheckSquare, Share2, Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload, CheckSquare, Share2, Filter,
} from '../components/icons'; } from '../components/icons';
import { ContactFiltersPanel } from '../components/contacts/ContactFiltersPanel';
import {
countActiveFilters, EMPTY_CONTACT_FILTERS, matchesContactFilters, type ContactListFilters,
} from '../lib/contact-list-filters';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types'; import type { ContactCard } from '../api/types';
import { import {
@@ -137,6 +141,9 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName); const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [filtersOpen, setFiltersOpen] = React.useState(false);
const [filters, setFilters] = React.useState<ContactListFilters>(EMPTY_CONTACT_FILTERS);
const activeFilterCount = countActiveFilters(filters);
// The company directory shows in place of the list (#1253). Not a stored // The company directory shows in place of the list (#1253). Not a stored
// category: it is the server's list, not the user's contacts. // category: it is the server's list, not the user's contacts.
const [showDirectory, setShowDirectory] = React.useState(false); const [showDirectory, setShowDirectory] = React.useState(false);
@@ -201,9 +208,11 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
); );
const visible = React.useMemo(() => { const visible = React.useMemo(() => {
if (!searchQuery) return sorted; // The filters sit under the search field and apply while it is open.
return sorted.filter((c) => matchesContactSearch(c, searchQuery)); const filtered = searchActive && activeFilterCount > 0 ? sorted.filter((c) => matchesContactFilters(c, filters)) : sorted;
}, [sorted, searchQuery]); if (!searchQuery) return filtered;
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
}, [sorted, searchQuery, filters, activeFilterCount, searchActive]);
const sections = React.useMemo<Section[]>(() => { const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible, sortByLastName); if (groupByLetter) return groupContacts(visible, sortByLastName);
@@ -538,8 +547,20 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
<X size={16} color={c.textMuted} /> <X size={16} color={c.textMuted} />
</Pressable> </Pressable>
)} )}
<Pressable
onPress={() => setFiltersOpen((v) => !v)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.filters.toggle', 'Filters')}
accessibilityState={{ expanded: filtersOpen }}
>
<Filter size={16} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
</Pressable>
</View> </View>
)} )}
{searchActive && filtersOpen && (
<ContactFiltersPanel filters={filters} onChange={setFilters} onClose={() => setFiltersOpen(false)} />
)}
{!selectionMode && ( {!selectionMode && (
<ScrollView <ScrollView