mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: filter contacts by company, job title, location, email domain, birthday month and details
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ContactListFilters>(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<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible, sortByLastName);
|
||||
@@ -538,8 +547,20 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
<X size={16} color={c.textMuted} />
|
||||
</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>
|
||||
)}
|
||||
{searchActive && filtersOpen && (
|
||||
<ContactFiltersPanel filters={filters} onChange={setFilters} onClose={() => setFiltersOpen(false)} />
|
||||
)}
|
||||
|
||||
{!selectionMode && (
|
||||
<ScrollView
|
||||
|
||||
Reference in New Issue
Block a user