diff --git a/src/components/contacts/ContactFiltersPanel.tsx b/src/components/contacts/ContactFiltersPanel.tsx new file mode 100644 index 00000000..c4bafc70 --- /dev/null +++ b/src/components/contacts/ContactFiltersPanel.tsx @@ -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 = (key: K, value: ContactListFilters[K]) => + onChange({ ...filters, [key]: value }); + + const field = (key: 'organization' | 'jobTitle' | 'location' | 'emailDomain', label: string, placeholder: string) => ( + + {label} + set(key, v)} + placeholder={placeholder} + placeholderTextColor={c.textMuted} + accessibilityLabel={label} + autoCapitalize="none" + autoCorrect={false} + style={styles.input} + /> + + ); + + const toggle = (key: 'hasEmail' | 'hasPhone' | 'hasPhoto', label: string, icon: React.ReactNode) => { + const value: TriState = filters[key]; + return ( + 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} + {label} + + ); + }; + + return ( + + + {t('contacts.filters.title', 'Advanced filters')} + {countActiveFilters(filters) > 0 && ( + onChange(EMPTY_CONTACT_FILTERS)} hitSlop={6} accessibilityRole="button"> + {t('contacts.filters.clear', 'Clear')} + + )} + + + + + + {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'))} + + + {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'))} + + {t('contacts.filters.birthday_month', 'Birthday in')} + + set('birthdayMonth', null)} + style={[styles.chip, filters.birthdayMonth === null && styles.chipOn]} + accessibilityRole="radio" + accessibilityState={{ selected: filters.birthdayMonth === null }} + > + {t('contacts.filters.any_month', 'Any month')} + + {monthNames.map((name, i) => { + const active = filters.birthdayMonth === i + 1; + return ( + set('birthdayMonth', i + 1)} + style={[styles.chip, active && styles.chipOn]} + accessibilityRole="radio" + accessibilityState={{ selected: active }} + > + {name} + + ); + })} + + + {toggle('hasEmail', t('contacts.filters.has_email', 'Has email'), )} + {toggle('hasPhone', t('contacts.filters.has_phone', 'Has phone'), )} + {toggle('hasPhoto', t('contacts.filters.has_photo', 'Has photo'), )} + + + ); +} + +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' }, + }); +} diff --git a/src/lib/__tests__/contact-list-filters.test.ts b/src/lib/__tests__/contact-list-filters.test.ts new file mode 100644 index 00000000..1c426ef4 --- /dev/null +++ b/src/lib/__tests__/contact-list-filters.test.ts @@ -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) => [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]); + }); +}); diff --git a/src/lib/contact-list-filters.ts b/src/lib/contact-list-filters.ts new file mode 100644 index 00000000..2f214b83 --- /dev/null +++ b/src/lib/contact-list-filters.ts @@ -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; +} diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx index f43e2be0..dc33e4a1 100644 --- a/src/screens/ContactsScreen.tsx +++ b/src/screens/ContactsScreen.tsx @@ -20,8 +20,12 @@ import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { listBackStep } from '../lib/list-back'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; 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'; +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 { ContactCard } from '../api/types'; import { @@ -137,6 +141,9 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null } const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName); const [searchQuery, setSearchQuery] = React.useState(''); + const [filtersOpen, setFiltersOpen] = React.useState(false); + const [filters, setFilters] = React.useState(EMPTY_CONTACT_FILTERS); + const activeFilterCount = countActiveFilters(filters); // 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. const [showDirectory, setShowDirectory] = React.useState(false); @@ -201,9 +208,11 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null } ); const visible = React.useMemo(() => { - if (!searchQuery) return sorted; - return sorted.filter((c) => matchesContactSearch(c, searchQuery)); - }, [sorted, searchQuery]); + // The filters sit under the search field and apply while it is open. + const filtered = searchActive && activeFilterCount > 0 ? sorted.filter((c) => matchesContactFilters(c, filters)) : sorted; + if (!searchQuery) return filtered; + return filtered.filter((c) => matchesContactSearch(c, searchQuery)); + }, [sorted, searchQuery, filters, activeFilterCount, searchActive]); const sections = React.useMemo(() => { if (groupByLetter) return groupContacts(visible, sortByLastName); @@ -538,8 +547,20 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null } )} + setFiltersOpen((v) => !v)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('contacts.filters.toggle', 'Filters')} + accessibilityState={{ expanded: filtersOpen }} + > + 0 ? c.primary : c.textMuted} /> + )} + {searchActive && filtersOpen && ( + setFiltersOpen(false)} /> + )} {!selectionMode && (