mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +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 { 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
|
||||||
|
|||||||
Reference in New Issue
Block a user