mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: search events in the calendar
This commit is contained in:
@@ -34,7 +34,7 @@ import {
|
|||||||
queryEvents,
|
queryEvents,
|
||||||
isCalendarAccessDenied,
|
isCalendarAccessDenied,
|
||||||
} from '../calendar';
|
} from '../calendar';
|
||||||
import { useCalendarStore } from '../../stores/calendar-store';
|
import { searchCalendarEvents, useCalendarStore } from '../../stores/calendar-store';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
import { saveOccurrenceEdit, deleteOccurrence, type SeriesEdit } from '../../lib/recurrence-scope-edit';
|
import { saveOccurrenceEdit, deleteOccurrence, type SeriesEdit } from '../../lib/recurrence-scope-edit';
|
||||||
import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
|
import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
|
||||||
@@ -754,6 +754,24 @@ live('live Stalwart calendar', () => {
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('event search', () => {
|
||||||
|
it('finds an event outside the loaded window by a word of its title, and a series by its stored event', async () => {
|
||||||
|
await connectSingleton();
|
||||||
|
const word = RUN.replace(/[^a-z0-9]/gi, '');
|
||||||
|
await createEvent({ '@type': 'Event', title: `${RUN} ${word}single`, start: '2027-12-10T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as Partial<CalendarEvent>, calendarId);
|
||||||
|
await createEvent(seriesData(`${word}series`, '2027-01-04T10:00:00', 4), calendarId);
|
||||||
|
await useCalendarStore.getState().fetchCalendars();
|
||||||
|
|
||||||
|
const single = await searchCalendarEvents([`${word}single`]);
|
||||||
|
expect(single.map((e) => e.title)).toEqual([`${RUN} ${word}single`]);
|
||||||
|
const series = await searchCalendarEvents([`${word}series`]);
|
||||||
|
expect(series).toHaveLength(1);
|
||||||
|
expect(series[0].recurrenceRules?.[0]?.count).toBe(4);
|
||||||
|
// Every word has to match.
|
||||||
|
expect(await searchCalendarEvents([`${word}single`, 'nosuchwordanywhere'])).toEqual([]);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
describe('a zero-length event (#837)', () => {
|
describe('a zero-length event (#837)', () => {
|
||||||
it('ends at its start, stored without a duration or imported from an .ics', async () => {
|
it('ends at its start, stored without a duration or imported from an .ics', async () => {
|
||||||
await connectSingleton();
|
await connectSingleton();
|
||||||
|
|||||||
@@ -1010,6 +1010,30 @@ export async function findEventsByUid(uid: string, accountId?: string): Promise<
|
|||||||
return getEvents(ids, accountId);
|
return getEvents(ids, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids of the events a CalendarEvent/query `filter` finds (the event
|
||||||
|
* search's `text` conditions), latest start first, at most `limit`. Not
|
||||||
|
* limited to a date window: a series is found by its stored event.
|
||||||
|
*/
|
||||||
|
export async function searchEventIds(
|
||||||
|
filter: Record<string, unknown>,
|
||||||
|
accountId?: string,
|
||||||
|
limit = 100,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const account = accountId || jmapClient.accountId;
|
||||||
|
return withCalendarTimeZone(async (timeZone) => {
|
||||||
|
const args: Record<string, unknown> = {
|
||||||
|
accountId: account,
|
||||||
|
filter,
|
||||||
|
sort: [{ property: 'start', isAscending: false }],
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
if (timeZone) args.timeZone = timeZone;
|
||||||
|
const res = await jmapClient.request([['CalendarEvent/query', args, '0']], USING);
|
||||||
|
return methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
|
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
|
||||||
// / reply / cancellation emails to participants. We pass it whenever an event
|
// / reply / cancellation emails to participants. We pass it whenever an event
|
||||||
// has participants so creating/updating/deleting a meeting notifies attendees.
|
// has participants so creating/updating/deleting a meeting notifies attendees.
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ArrowLeft, MapPin, Repeat, Search, X } from '../icons';
|
||||||
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store';
|
||||||
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
|
import { displayNow } from '../../lib/calendar-timezone';
|
||||||
|
import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search';
|
||||||
|
import {
|
||||||
|
getEventColor,
|
||||||
|
getEventEndDate,
|
||||||
|
getEventStartDate,
|
||||||
|
getPrimaryCalendarId,
|
||||||
|
timePattern,
|
||||||
|
type TimeFormat,
|
||||||
|
} from '../../lib/calendar-utils';
|
||||||
|
import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
interface CalendarSearchSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
calendars: Calendar[];
|
||||||
|
timeFormat?: TimeFormat;
|
||||||
|
currentUserEmails?: string[];
|
||||||
|
/** Called once the search is closed. */
|
||||||
|
onSelectEvent: (event: CalendarEvent) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row =
|
||||||
|
| { kind: 'header'; key: string; label: string }
|
||||||
|
| { kind: 'event'; key: string; event: CalendarEvent };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the events by title, place, description and participants (webmail
|
||||||
|
* calendar-search). The loaded events answer at once; the server's text
|
||||||
|
* search adds the rest of the calendar, whatever the date.
|
||||||
|
*/
|
||||||
|
export function CalendarSearchSheet({
|
||||||
|
visible,
|
||||||
|
calendars,
|
||||||
|
timeFormat,
|
||||||
|
currentUserEmails = [],
|
||||||
|
onSelectEvent,
|
||||||
|
onClose,
|
||||||
|
}: CalendarSearchSheetProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const { locale, t } = useCalendarLocale();
|
||||||
|
const loaded = useCalendarStore((s) => s.events);
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setQuery('');
|
||||||
|
setRemote(null);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const terms = React.useMemo(() => searchTerms(query), [query]);
|
||||||
|
const key = terms.join(' ');
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible || terms.length === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
searchCalendarEvents(terms)
|
||||||
|
.then((events) => { if (!cancelled) setRemote({ query: key, events }); })
|
||||||
|
// A server that can't search leaves the loaded matches.
|
||||||
|
.catch(() => { if (!cancelled) setRemote({ query: key, events: [] }); });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible, key]);
|
||||||
|
|
||||||
|
// iOS can't present the detail sheet while this one is still going away:
|
||||||
|
// the event opens once it is gone (onDismiss, iOS only).
|
||||||
|
const pending = React.useRef<CalendarEvent | null>(null);
|
||||||
|
const choose = (event: CalendarEvent) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
pending.current = event;
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
onSelectEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const answered = remote?.query === key ? remote.events : null;
|
||||||
|
const searching = terms.length > 0 && !answered;
|
||||||
|
const results = React.useMemo(() => {
|
||||||
|
if (terms.length === 0) return null;
|
||||||
|
const local = loaded.filter((e) => eventMatches(e, terms));
|
||||||
|
return orderSearchResults(local, answered ?? [], displayNow().getTime());
|
||||||
|
}, [loaded, terms, answered]);
|
||||||
|
|
||||||
|
const rows = React.useMemo<Row[]>(() => {
|
||||||
|
if (!results) return [];
|
||||||
|
const out: Row[] = [];
|
||||||
|
if (results.upcoming.length > 0) {
|
||||||
|
out.push({ kind: 'header', key: 'h-upcoming', label: t('calendar.search.upcoming', 'Upcoming') });
|
||||||
|
for (const event of results.upcoming) out.push({ kind: 'event', key: `u-${event.id}`, event });
|
||||||
|
}
|
||||||
|
if (results.past.length > 0) {
|
||||||
|
out.push({ kind: 'header', key: 'h-past', label: t('calendar.search.past', 'Past') });
|
||||||
|
for (const event of results.past) out.push({ kind: 'event', key: `p-${event.id}`, event });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [results, t]);
|
||||||
|
|
||||||
|
const time = timePattern(timeFormat);
|
||||||
|
const thisYear = displayNow().getFullYear();
|
||||||
|
const renderEvent = (event: CalendarEvent) => {
|
||||||
|
const start = getEventStartDate(event);
|
||||||
|
const end = getEventEndDate(event);
|
||||||
|
const calendar = calendars.find((cal) => cal.id === getPrimaryCalendarId(event));
|
||||||
|
const color = getEventColor(event, calendars);
|
||||||
|
const inactive = isInactiveEvent(event, currentUserEmails);
|
||||||
|
const place = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||||
|
const meta = [place, calendar?.name].filter(Boolean).join(' · ');
|
||||||
|
// A series the server found by its stored event.
|
||||||
|
const repeats = !event.recurrenceId && !!event.recurrenceRules?.length;
|
||||||
|
const title = event.title || t('calendar.events.no_title', '(No title)');
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
onPress={() => choose(event)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={title}
|
||||||
|
>
|
||||||
|
<View style={styles.when}>
|
||||||
|
<Text style={styles.whenDay}>
|
||||||
|
{format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.whenTime}>
|
||||||
|
{event.showWithoutTime
|
||||||
|
? t('calendar.events.all_day', 'All day')
|
||||||
|
: `${format(start, time, { locale })} – ${format(end, time, { locale })}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.dot, inactive ? { borderWidth: 1.5, borderColor: color } : { backgroundColor: color }]} />
|
||||||
|
<View style={styles.what}>
|
||||||
|
<Text style={[styles.title, inactive && styles.inactive]} numberOfLines={1}>{title}</Text>
|
||||||
|
{(meta || repeats) ? (
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
{place ? <MapPin size={12} color={c.textMuted} /> : null}
|
||||||
|
{meta ? <Text style={styles.meta} numberOfLines={1}>{meta}</Text> : null}
|
||||||
|
{repeats && (
|
||||||
|
<>
|
||||||
|
<Repeat size={12} color={c.textMuted} />
|
||||||
|
<Text style={styles.meta}>{t('calendar.search.repeats', 'Repeats')}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaModal
|
||||||
|
supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="fullScreen"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onDismiss={() => {
|
||||||
|
const event = pending.current;
|
||||||
|
pending.current = null;
|
||||||
|
if (event) onSelectEvent(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom', 'left', 'right']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.headerBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.search.close', 'Close search')}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Search size={16} color={c.textMuted} />
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder={t('calendar.search.title', 'Search events')}
|
||||||
|
placeholderTextColor={c.mutedForeground}
|
||||||
|
style={styles.input}
|
||||||
|
autoFocus
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="search"
|
||||||
|
accessibilityLabel={t('calendar.search.title', 'Search events')}
|
||||||
|
/>
|
||||||
|
{query ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setQuery('')}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.search.clear', 'Clear search')}
|
||||||
|
>
|
||||||
|
<X size={16} color={c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{searching && rows.length === 0 ? (
|
||||||
|
<View style={styles.center}><ActivityIndicator color={c.primary} /></View>
|
||||||
|
) : results && rows.length === 0 ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.emptyTitle}>
|
||||||
|
{t('calendar.search.no_results', 'No events match “{query}”', { query: query.trim() })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.emptyHint}>
|
||||||
|
{t('calendar.search.no_results_hint', 'Search looks at titles, places, descriptions and guests.')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={rows}
|
||||||
|
keyExtractor={(row) => row.key}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
automaticallyAdjustKeyboardInsets
|
||||||
|
ListHeaderComponent={searching ? <ActivityIndicator style={styles.more} color={c.primary} /> : null}
|
||||||
|
renderItem={({ item }) => (item.kind === 'header'
|
||||||
|
? <Text style={styles.section}>{item.label}</Text>
|
||||||
|
: renderEvent(item.event))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
</SafeAreaModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: c.border,
|
||||||
|
},
|
||||||
|
headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
field: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
backgroundColor: c.surface,
|
||||||
|
},
|
||||||
|
input: { flex: 1, ...typography.body, color: c.text, paddingVertical: spacing.sm },
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl, gap: spacing.sm },
|
||||||
|
emptyTitle: { ...typography.bodyMedium, color: c.text, textAlign: 'center' },
|
||||||
|
emptyHint: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
|
||||||
|
more: { paddingVertical: spacing.sm },
|
||||||
|
section: {
|
||||||
|
...typography.captionMedium,
|
||||||
|
color: c.textSecondary,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: c.surface },
|
||||||
|
when: { width: 104 },
|
||||||
|
whenDay: { ...typography.body, color: c.text },
|
||||||
|
whenTime: { ...typography.caption, color: c.textMuted },
|
||||||
|
dot: { width: 9, height: 9, borderRadius: 5, marginTop: 6 },
|
||||||
|
what: { flex: 1, minWidth: 0 },
|
||||||
|
title: { ...typography.bodyMedium, color: c.text },
|
||||||
|
inactive: { textDecorationLine: 'line-through', color: c.textMuted },
|
||||||
|
metaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: 2 },
|
||||||
|
meta: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { CalendarEvent } from '../../api/types';
|
||||||
|
import { eventMatches, orderSearchResults, searchFilter, searchTerms } from '../calendar-search';
|
||||||
|
|
||||||
|
const ev = (partial: Partial<CalendarEvent>): CalendarEvent => ({
|
||||||
|
id: 'e', uid: 'u', title: 'Event', calendarIds: { c: true }, start: '2026-03-02T09:00:00', duration: 'PT1H',
|
||||||
|
...partial,
|
||||||
|
} as CalendarEvent);
|
||||||
|
|
||||||
|
describe('calendar search', () => {
|
||||||
|
it('asks the server for every word', () => {
|
||||||
|
expect(searchTerms(' Team Sync ')).toEqual(['team', 'sync']);
|
||||||
|
expect(searchFilter(['team'])).toEqual({ text: 'team' });
|
||||||
|
expect(searchFilter(['team', 'sync'])).toEqual({ operator: 'AND', conditions: [{ text: 'team' }, { text: 'sync' }] });
|
||||||
|
expect(searchFilter([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the loaded events by title, place, description and participants', () => {
|
||||||
|
const event = ev({
|
||||||
|
title: 'Quarterly review',
|
||||||
|
description: 'Budget',
|
||||||
|
locations: { l: { '@type': 'Location', name: 'Room Atlas' } } as never,
|
||||||
|
participants: { p: { calendarAddress: 'mailto:dana@example.com', name: 'Dana' } } as never,
|
||||||
|
});
|
||||||
|
expect(eventMatches(event, searchTerms('review atlas'))).toBe(true);
|
||||||
|
expect(eventMatches(event, searchTerms('budget dana@example'))).toBe(true);
|
||||||
|
expect(eventMatches(event, searchTerms('review lunch'))).toBe(false);
|
||||||
|
expect(eventMatches(event, [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a series once, by its next occurrence, and the server\'s finds the window lacks', () => {
|
||||||
|
const now = new Date('2026-03-10T12:00:00').getTime();
|
||||||
|
const occurrence = (day: string) => ev({ id: `s:${day}`, uid: 'series', title: 'Standup', start: `2026-03-${day}T09:00:00`, recurrenceId: `2026-03-${day}T09:00:00` });
|
||||||
|
const local = [occurrence('09'), occurrence('16'), occurrence('11')];
|
||||||
|
const server = [
|
||||||
|
ev({ id: 'series-base', uid: 'series', title: 'Standup', start: '2026-01-05T09:00:00', recurrenceRules: [{ frequency: 'weekly' }] as never }),
|
||||||
|
ev({ id: 'old', uid: 'old', title: 'Standup retro', start: '2025-11-01T09:00:00' }),
|
||||||
|
];
|
||||||
|
const { upcoming, past } = orderSearchResults(local, server, now);
|
||||||
|
expect(upcoming.map((e) => e.id)).toEqual(['s:11']);
|
||||||
|
expect(past.map((e) => e.id)).toEqual(['old']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { CalendarEvent } from '../api/types';
|
||||||
|
import { getEventEndDate, getEventStartDate } from './calendar-utils';
|
||||||
|
|
||||||
|
// Event search in the calendar (webmail components/calendar/calendar-search
|
||||||
|
// and hooks/use-calendar-event-search): the loaded events answer at once,
|
||||||
|
// the server's CalendarEvent/query `text` filter adds the rest.
|
||||||
|
|
||||||
|
/** The words of a search, lower case; every one has to match. */
|
||||||
|
export function searchTerms(query: string): string[] {
|
||||||
|
return query.toLowerCase().split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CalendarEvent/query filter for the words: one `text` condition each. */
|
||||||
|
export function searchFilter(terms: string[]): Record<string, unknown> | null {
|
||||||
|
if (terms.length === 0) return null;
|
||||||
|
if (terms.length === 1) return { text: terms[0] };
|
||||||
|
return { operator: 'AND', conditions: terms.map((text) => ({ text })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a search looks at: title, description, places and participants. */
|
||||||
|
function eventFields(event: CalendarEvent): string[] {
|
||||||
|
const fields = [event.title ?? '', event.description ?? ''];
|
||||||
|
for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name);
|
||||||
|
for (const p of Object.values(event.participants ?? {})) {
|
||||||
|
if (p.name) fields.push(p.name);
|
||||||
|
if (p.email) fields.push(p.email);
|
||||||
|
if (p.calendarAddress) fields.push(p.calendarAddress.replace(/^mailto:/i, ''));
|
||||||
|
}
|
||||||
|
return fields.map((f) => f.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether every word of the search is in one of the event's fields. */
|
||||||
|
export function eventMatches(event: CalendarEvent, terms: string[]): boolean {
|
||||||
|
if (terms.length === 0) return false;
|
||||||
|
const fields = eventFields(event);
|
||||||
|
return terms.every((term) => fields.some((field) => field.includes(term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSearchResults {
|
||||||
|
/** Still to come or going on, soonest first. */
|
||||||
|
upcoming: CalendarEvent[];
|
||||||
|
/** Over, latest first. */
|
||||||
|
past: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per event: a series found as loaded occurrences and as the
|
||||||
|
// server's base event shows once. An occurrence renamed on its own keeps a
|
||||||
|
// row of its own.
|
||||||
|
const seriesKey = (event: CalendarEvent) =>
|
||||||
|
`${event.accountId ?? ''} ${event.uid || event.originalId || event.id} ${event.title ?? ''}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows of a search: of a series' loaded occurrences the next one to
|
||||||
|
* come, else the last; the server's events add what the loaded window
|
||||||
|
* lacks. Tasks and events without a start are left out.
|
||||||
|
*/
|
||||||
|
export function orderSearchResults(
|
||||||
|
local: CalendarEvent[],
|
||||||
|
remote: CalendarEvent[],
|
||||||
|
now: number,
|
||||||
|
): EventSearchResults {
|
||||||
|
const ends = (event: CalendarEvent) => getEventEndDate(event).getTime();
|
||||||
|
const starts = (event: CalendarEvent) => getEventStartDate(event).getTime();
|
||||||
|
const bySeries = new Map<string, CalendarEvent>();
|
||||||
|
for (const event of local) {
|
||||||
|
if (!event.start) continue;
|
||||||
|
const key = seriesKey(event);
|
||||||
|
const kept = bySeries.get(key);
|
||||||
|
if (!kept) {
|
||||||
|
bySeries.set(key, event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const keptUpcoming = ends(kept) >= now;
|
||||||
|
const upcoming = ends(event) >= now;
|
||||||
|
if (upcoming && (!keptUpcoming || starts(event) < starts(kept))) bySeries.set(key, event);
|
||||||
|
else if (!upcoming && !keptUpcoming && starts(event) > starts(kept)) bySeries.set(key, event);
|
||||||
|
}
|
||||||
|
for (const event of remote) {
|
||||||
|
if (!event.start) continue;
|
||||||
|
const key = seriesKey(event);
|
||||||
|
if (!bySeries.has(key)) bySeries.set(key, event);
|
||||||
|
}
|
||||||
|
const upcoming: CalendarEvent[] = [];
|
||||||
|
const past: CalendarEvent[] = [];
|
||||||
|
for (const event of bySeries.values()) {
|
||||||
|
if (Number.isNaN(starts(event))) continue;
|
||||||
|
(ends(event) >= now ? upcoming : past).push(event);
|
||||||
|
}
|
||||||
|
upcoming.sort((a, b) => starts(a) - starts(b));
|
||||||
|
past.sort((a, b) => starts(b) - starts(a));
|
||||||
|
return { upcoming, past };
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
Menu,
|
Menu,
|
||||||
Rows2,
|
Rows2,
|
||||||
|
Search,
|
||||||
Square,
|
Square,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import {
|
import {
|
||||||
@@ -60,6 +61,7 @@ import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
|
|||||||
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
|
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
|
||||||
import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet';
|
import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet';
|
||||||
import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet';
|
import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet';
|
||||||
|
import { CalendarSearchSheet } from '../components/calendar/CalendarSearchSheet';
|
||||||
import {
|
import {
|
||||||
computeScrollWindow,
|
computeScrollWindow,
|
||||||
fixedScrollWindowState,
|
fixedScrollWindowState,
|
||||||
@@ -225,6 +227,7 @@ export default function CalendarScreen() {
|
|||||||
}, [scrollWindow]);
|
}, [scrollWindow]);
|
||||||
|
|
||||||
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
||||||
|
const [searchVisible, setSearchVisible] = React.useState(false);
|
||||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||||
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
||||||
const [modalVisible, setModalVisible] = React.useState(false);
|
const [modalVisible, setModalVisible] = React.useState(false);
|
||||||
@@ -982,6 +985,18 @@ export default function CalendarScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.headerBtn}
|
||||||
|
onPress={() => {
|
||||||
|
setMonthPanelOpen(false);
|
||||||
|
setSearchVisible(true);
|
||||||
|
}}
|
||||||
|
hitSlop={4}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.search.title', 'Search events')}
|
||||||
|
>
|
||||||
|
<Search size={20} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
{/* Without free scrolling the views don't swipe: keep arrows. */}
|
{/* Without free scrolling the views don't swipe: keep arrows. */}
|
||||||
{!freeScroll && (
|
{!freeScroll && (
|
||||||
<>
|
<>
|
||||||
@@ -1283,6 +1298,15 @@ export default function CalendarScreen() {
|
|||||||
onClose={() => setCalendarEditTarget(null)}
|
onClose={() => setCalendarEditTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CalendarSearchSheet
|
||||||
|
visible={searchVisible}
|
||||||
|
calendars={calendars}
|
||||||
|
timeFormat={calendarTimeFormat}
|
||||||
|
currentUserEmails={currentUserEmails}
|
||||||
|
onSelectEvent={handleSelectEvent}
|
||||||
|
onClose={() => setSearchVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<CalendarShareSheet
|
<CalendarShareSheet
|
||||||
calendar={shareTarget}
|
calendar={shareTarget}
|
||||||
onShare={shareCalendar}
|
onShare={shareCalendar}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ vi.mock('../../api/calendar', () => ({
|
|||||||
setDefaultCalendar: vi.fn(async () => undefined),
|
setDefaultCalendar: vi.fn(async () => undefined),
|
||||||
supportsSyntheticCalendarIds: vi.fn(async () => false),
|
supportsSyntheticCalendarIds: vi.fn(async () => false),
|
||||||
queryExpandedEvents: vi.fn(async () => []),
|
queryExpandedEvents: vi.fn(async () => []),
|
||||||
|
searchEventIds: vi.fn(async () => []),
|
||||||
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
|
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
|
||||||
resetSyntheticIdSupport: vi.fn(),
|
resetSyntheticIdSupport: vi.fn(),
|
||||||
resetTimeZoneChecks: vi.fn(),
|
resetTimeZoneChecks: vi.fn(),
|
||||||
@@ -47,7 +48,7 @@ vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() }))
|
|||||||
|
|
||||||
import * as calendarApi from '../../api/calendar';
|
import * as calendarApi from '../../api/calendar';
|
||||||
import type { CalendarEvent } from '../../api/types';
|
import type { CalendarEvent } from '../../api/types';
|
||||||
import { CalendarAccountChangedError, useCalendarStore } from '../calendar-store';
|
import { CalendarAccountChangedError, searchCalendarEvents, useCalendarStore } from '../calendar-store';
|
||||||
|
|
||||||
const mockUpdate = vi.mocked(calendarApi.updateEvent);
|
const mockUpdate = vi.mocked(calendarApi.updateEvent);
|
||||||
const mockDelete = vi.mocked(calendarApi.deleteEvents);
|
const mockDelete = vi.mocked(calendarApi.deleteEvents);
|
||||||
@@ -259,3 +260,37 @@ describe('a write the account switch overtook', () => {
|
|||||||
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
|
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('searchCalendarEvents', () => {
|
||||||
|
const mockSearch = vi.mocked(calendarApi.searchEventIds);
|
||||||
|
|
||||||
|
it('searches every account the store shows and edits a find in its own account', async () => {
|
||||||
|
useCalendarStore.setState({
|
||||||
|
calendars: [{ id: 'b', name: 'Mine' }, { id: 'acc-2:t', originalId: 't', accountId: 'acc-2', name: 'Team', isShared: true }] as never,
|
||||||
|
events: [],
|
||||||
|
});
|
||||||
|
mockSearch.mockImplementation(async (_filter, accountId) => (accountId === 'acc-2' ? ['x'] : ['c']));
|
||||||
|
mockGetEvents.mockImplementation(async (ids: string[], accountId?: string) => ids.map((id) => ({
|
||||||
|
...event(id, accountId ? 'Team review' : 'My review'),
|
||||||
|
calendarIds: (accountId ? { t: true } : { b: true }) as Record<string, boolean>,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const found = await searchCalendarEvents(['review']);
|
||||||
|
|
||||||
|
expect(mockSearch).toHaveBeenCalledWith({ text: 'review' }, undefined);
|
||||||
|
expect(mockSearch).toHaveBeenCalledWith({ text: 'review' }, 'acc-2');
|
||||||
|
expect(found.map((e) => e.id).sort()).toEqual(['acc-2:x', 'c']);
|
||||||
|
// Outside the loaded window, yet a change reaches the owner's account.
|
||||||
|
await useCalendarStore.getState().updateEvent('acc-2:x', { title: 'Renamed' });
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith('x', { title: 'Renamed' }, undefined, 'acc-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands nothing found before an account switch to the next account', async () => {
|
||||||
|
mockSearch.mockImplementationOnce(async () => {
|
||||||
|
switchAccount();
|
||||||
|
return ['c'];
|
||||||
|
});
|
||||||
|
await expect(searchCalendarEvents(['review'])).rejects.toBeInstanceOf(CalendarAccountChangedError);
|
||||||
|
expect(mockGetEvents).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
queryEvents,
|
queryEvents,
|
||||||
getEvents as fetchEvents,
|
getEvents as fetchEvents,
|
||||||
getEventIdentities,
|
getEventIdentities,
|
||||||
|
searchEventIds,
|
||||||
scanCalendarObjects,
|
scanCalendarObjects,
|
||||||
isCalendarAccessDenied,
|
isCalendarAccessDenied,
|
||||||
noteCalendarAccessError,
|
noteCalendarAccessError,
|
||||||
@@ -59,6 +60,7 @@ import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-comp
|
|||||||
import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache';
|
import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache';
|
||||||
import { requestDeviceSync } from '../device-sync/app/request';
|
import { requestDeviceSync } from '../device-sync/app/request';
|
||||||
import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort';
|
import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort';
|
||||||
|
import { searchFilter } from '../lib/calendar-search';
|
||||||
import { CALENDAR_AUTHORITY } from '../device-sync/types';
|
import { CALENDAR_AUTHORITY } from '../device-sync/types';
|
||||||
|
|
||||||
// extendRange merges up to twice the requested range (at least half a year,
|
// extendRange merges up to twice the requested range (at least half a year,
|
||||||
@@ -512,6 +514,41 @@ export async function loadEventsInRange(
|
|||||||
// here.
|
// here.
|
||||||
const knownMasters = new Map<string, CalendarEvent>();
|
const knownMasters = new Map<string, CalendarEvent>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events of every calendar account the store shows that the server
|
||||||
|
* finds for `terms` (CalendarEvent/query `text`), whatever their date: a
|
||||||
|
* series by its stored event. They become known like a fetched master, so
|
||||||
|
* an edit or a delete from the detail sheet reaches their own account under
|
||||||
|
* their raw id. Throws CalendarAccountChangedError when the store was reset
|
||||||
|
* meanwhile; a failing shared account is skipped.
|
||||||
|
*/
|
||||||
|
export async function searchCalendarEvents(terms: string[]): Promise<CalendarEvent[]> {
|
||||||
|
const filter = searchFilter(terms);
|
||||||
|
if (!filter || !jmapClient.isConnected) return [];
|
||||||
|
const generation = storeGeneration;
|
||||||
|
const calendars = useCalendarStore.getState().calendars;
|
||||||
|
const accounts = new Set<string | undefined>([undefined]);
|
||||||
|
for (const cal of calendars) if (cal.accountId) accounts.add(cal.accountId);
|
||||||
|
const found: CalendarEvent[] = [];
|
||||||
|
for (const accountId of accounts) {
|
||||||
|
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
|
||||||
|
if (accountId && isCalendarAccessDenied(accountId)) continue;
|
||||||
|
try {
|
||||||
|
const ids = await searchEventIds(filter, accountId);
|
||||||
|
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
|
||||||
|
const events = ids.length > 0 ? await fetchEvents(ids, accountId) : [];
|
||||||
|
found.push(...events
|
||||||
|
.filter((e) => !isTaskLikeObject(e) && !!e.start)
|
||||||
|
.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
|
||||||
|
} catch (err) {
|
||||||
|
if (!accountId || err instanceof CalendarAccountChangedError) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
|
||||||
|
for (const event of found) knownMasters.set(event.id, event);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationTarget {
|
export interface MutationTarget {
|
||||||
storeEvent?: CalendarEvent;
|
storeEvent?: CalendarEvent;
|
||||||
/** Raw JMAP id the change is sent to. */
|
/** Raw JMAP id the change is sent to. */
|
||||||
|
|||||||
Reference in New Issue
Block a user