diff --git a/locales/rn/en.json b/locales/rn/en.json index 802dfea7..bbc1c798 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -6,6 +6,7 @@ "not_starred": "Not starred" }, "calendar": { + "account_changed": "The account changed while the calendar was loading or saving.", "alerts": { "minutes_after": "{count, plural, one {# minute after} other {# minutes after}}" }, @@ -105,6 +106,16 @@ "occurrence_count": "{count, plural, one {# occurrence} other {# occurrences}}", "until_date": "Until {date}" }, + "search": { + "clear": "Clear search", + "close": "Close search", + "no_results": "No events match “{query}”", + "no_results_hint": "Search looks at titles, places, descriptions and guests.", + "past": "Past", + "repeats": "Repeats", + "title": "Search events", + "upcoming": "Upcoming" + }, "settings": { "device_sync_desc": "Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.", "device_sync_title": "Sync to this device", @@ -123,6 +134,7 @@ "shared_with": "Shared with" }, "subscription": { + "account_changed": "The account changed while the subscription was syncing. It syncs again in its own account.", "add_feed": "Add a feed", "feed_too_large": "Feed is too large", "fetch_failed": "Could not fetch feed (HTTP {status})", diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 28b9d1d6..ea16e878 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -104,6 +104,33 @@ describe('calendar operations', () => { }, ); + it('keeps a refusal answered after a new login out of that login\'s memory', async () => { + const client = jmapClient as unknown as { currentSession?: unknown }; + client.currentSession = { + accounts: { + 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } }, + 'acc-team': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } }, + }, + }; + try { + resetCalendarAccessDenied(); + mockRequest.mockImplementation(async (calls: [string, { accountId: string }, string][]) => { + if (calls.some(([, args]) => args.accountId === 'acc-team')) { + // The previous login was refused; the next one signed in meanwhile. + resetCalendarAccessDenied(); + return { methodResponses: [['error', { type: 'forbidden' }, '0']] }; + } + return { methodResponses: calls.map(([name, , id]) => [name, name === 'Calendar/get' ? { list: [] } : {}, id]) }; + }); + await getCalendars(); + expect(isCalendarAccessDenied('acc-team')).toBe(false); + } finally { + client.currentSession = undefined; + mockRequest.mockReset(); + resetCalendarAccessDenied(); + } + }); + it('should fetch calendars', async () => { const calendars = [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }]; mockRequest.mockResolvedValue({ @@ -368,6 +395,22 @@ describe('calendar operations', () => { }); describe('getEvents', () => { + // #837: Stalwart stores a DTEND equal to DTSTART as no duration and ends + // the event at 23:59:59 of its start day. + it('ends a timed event without a duration at its start, and leaves tasks alone', async () => { + mockRequest.mockResolvedValue(getAnswer([ + { id: 'z', '@type': 'Event', start: '2026-03-02T09:00:00', utcStart: '2026-03-02T08:00:00Z', utcEnd: '2026-03-02T22:59:59Z' }, + { id: 't', '@type': 'Task', start: '2026-03-02T09:00:00', utcStart: '2026-03-02T08:00:00Z', due: '2026-03-03T09:00:00' }, + { id: 'a', '@type': 'Event', start: '2026-03-02T00:00:00', showWithoutTime: true }, + ])); + + const [zero, task, allDay] = await getEvents(['z', 't', 'a']); + + expect(zero).toMatchObject({ duration: 'PT0S', utcEnd: '2026-03-02T08:00:00Z' }); + expect(task).not.toHaveProperty('duration'); + expect(allDay).not.toHaveProperty('duration'); + }); + it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => { // Stalwart (calcard) names the property `recurrenceRule` (singular, // single object) — requesting the RFC 8984 plural form returns no diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index f112f0f5..e5b382e8 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -34,13 +34,15 @@ import { queryEvents, isCalendarAccessDenied, } from '../calendar'; -import { useCalendarStore } from '../../stores/calendar-store'; +import { searchCalendarEvents, useCalendarStore } from '../../stores/calendar-store'; import { useSettingsStore } from '../../stores/settings-store'; import { saveOccurrenceEdit, deleteOccurrence, type SeriesEdit } from '../../lib/recurrence-scope-edit'; import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times'; import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants'; import { expandRecurringEvents } from '../../lib/recurrence-expansion'; import { getEventStartDate } from '../../lib/calendar-utils'; +import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts'; +import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler'; const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; @@ -650,9 +652,11 @@ live('live Stalwart calendar', () => { expect(useCalendarStore.getState().error).toBeNull(); const toSharer = spy.mock.calls.filter(([calls]) => (calls as unknown as [string, Record][]).some(([, args]) => args.accountId === sharer)); - // At most the one probe: no retry on every load. - expect(toSharer.length).toBeLessThanOrEqual(1); - if (toSharer.length === 1) expect(isCalendarAccessDenied(sharer)).toBe(true); + // Both servers list the account for calendars: the one probe, then + // no retry on every load. + expect(client.currentSession?.accounts?.[sharer]?.accountCapabilities?.[CAPABILITIES.CALENDARS], 'the session lists the sharer for calendars').toBeTruthy(); + expect(toSharer).toHaveLength(1); + expect(isCalendarAccessDenied(sharer)).toBe(true); } finally { spy.mockRestore(); } @@ -696,6 +700,199 @@ live('live Stalwart calendar', () => { }, 60_000); }); + describe('a calendar\'s default reminders (#1209)', () => { + let defaultsCalendarId = ''; + let otherCalendarId = ''; + + afterAll(async () => { + const ids = [defaultsCalendarId, otherCalendarId].filter(Boolean); + if (ids.length > 0) { + await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: ids, onDestroyRemoveEvents: true }, '0']]).catch(() => undefined); + } + }, 30_000); + + it('are set and cleared in the calendar and given to a new event that uses them', async () => { + await connectSingleton(); + const [[, made]] = await raw(client, [['Calendar/set', { + accountId: client.accountId, create: { c: { name: `${RUN} defaults`, color: '#3b82f6' } }, + }, '0']]); + defaultsCalendarId = made.created?.c?.id ?? ''; + expect(defaultsCalendarId, JSON.stringify(made.notCreated)).toBeTruthy(); + await useCalendarStore.getState().fetchCalendars(); + + // As the calendar editor saves them. + // Another calendar of the account with defaults under the plain ids + // the editor wrote before: 1.0 wants them unique in the account. + const plain = await raw(client, [['Calendar/set', { + accountId: client.accountId, + create: { o: { name: `${RUN} other defaults`, defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 5 }]) } }, + }, '0']]); + otherCalendarId = plain[0][1].created?.o?.id ?? ''; + const target = { id: defaultsCalendarId }; + await useCalendarStore.getState().updateCalendar(defaultsCalendarId, { + defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix(target, false)), + defaultAlertsWithoutTime: remindersToDefaultAlerts([{ minutesBefore: 60 * 24 }], {}, defaultAlertIdPrefix(target, true)), + }); + await useCalendarStore.getState().fetchCalendars(); + const cal = useCalendarStore.getState().calendars.find((c) => c.id === defaultsCalendarId)!; + expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]); + expect(alertsToReminders(calendarDefaultAlerts(cal, true))).toEqual([{ minutesBefore: 60 * 24 }]); + + // As the event editor creates an event on the defaults. + const created = await useCalendarStore.getState().createEvent({ + '@type': 'Event', title: `${RUN} on defaults`, start: '2027-12-03T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', useDefaultAlerts: true, + } as Partial, defaultsCalendarId); + const [stored] = await getEvents([created.id]); + expect(stored.useDefaultAlerts).toBe(true); + const effective = getEffectiveAlerts(stored, useCalendarStore.getState().calendars) ?? {}; + expect(alertsToReminders(effective)).toEqual([{ minutesBefore: 15 }]); + + // Emptied. + await useCalendarStore.getState().updateCalendar(defaultsCalendarId, { defaultAlertsWithTime: remindersToDefaultAlerts([]) }); + const [[, after]] = await raw(client, [['Calendar/get', { accountId: client.accountId, ids: [defaultsCalendarId], properties: ['defaultAlertsWithTime'] }, '0']]); + expect(Object.keys(after.list[0].defaultAlertsWithTime ?? {})).toEqual([]); + }, 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, 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)', () => { + it('ends at its start, stored without a duration or imported from an .ics', async () => { + await connectSingleton(); + // As another client stores it: no duration at all. + const [[, made]] = await raw(client, [['CalendarEvent/set', { + accountId: client.accountId, + create: { z: { '@type': 'Event', title: `${RUN} zero`, start: '2027-12-01T09:00:00', timeZone: 'Europe/Berlin', calendarIds: { [calendarId]: true } } }, + }, '0']]); + const id = made.created?.z?.id; + expect(id, JSON.stringify(made.notCreated)).toBeTruthy(); + const [read] = await getEvents([id]); + expect(read.utcStart).toBe('2027-12-01T08:00:00Z'); + expect(read.utcEnd).toBe(read.utcStart); + + // An .ics whose DTEND equals its DTSTART, imported. + const uid = `${RUN}-zero-import`; + await useCalendarStore.getState().fetchCalendars(); + const { imported } = await useCalendarStore.getState().importEvents([ + { uid, title: `${RUN} zero imported`, start: '2027-12-02T09:00:00', timeZone: 'Europe/Berlin' }, + ], calendarId); + expect(imported).toBe(1); + const [stored] = await seriesByUid(client, uid); + const [[, got]] = await raw(client, [['CalendarEvent/get', { accountId: client.accountId, ids: [stored.id], properties: ['duration', 'utcStart', 'utcEnd'] }, '0']]); + expect(got.list[0].utcEnd).toBe(got.list[0].utcStart); + }, 60_000); + }); + + describe('a subscribed feed', () => { + const FEED_URL = 'https://feeds.invalid/live.ics'; + let feed = ''; + const realFetch = globalThis.fetch; + + const vevent = (uid: string, title: string, start: string, extra = '') => + `BEGIN:VEVENT\r\nUID:${RUN}-${uid}\r\nDTSTAMP:20271101T000000Z\r\nDTSTART:${start}\r\nDURATION:PT1H\r\nSUMMARY:${RUN} ${title}\r\n${extra}END:VEVENT\r\n`; + const calendar = (...events: string[]) => + `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//live//EN\r\n${events.join('')}END:VCALENDAR\r\n`; + + async function mirrorEvents(calendarId: string): Promise[]> { + const res = await raw(client, [ + ['CalendarEvent/query', { accountId: client.accountId, filter: { inCalendar: calendarId } }, 'q'], + ['CalendarEvent/get', { + accountId: client.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'uid', 'title', 'start', 'locations', 'recurrenceRule'], + }, 'g'], + ]); + return (res.find((r) => r[2] === 'g')?.[1].list ?? []) as Record[]; + } + + beforeAll(() => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) !== FEED_URL) return realFetch(input, init); + return new Response(feed, { status: 200, headers: { 'Content-Type': 'text/calendar' } }); + }) as typeof fetch; + }); + + afterAll(async () => { + globalThis.fetch = realFetch; + const { useCalendarSubscriptionsStore } = await import('../../stores/calendar-subscriptions-store'); + for (const sub of useCalendarSubscriptionsStore.getState().subscriptions) { + if (sub.calendarId && sub.name.includes(RUN)) { + await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: [sub.calendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined); + } + } + useCalendarSubscriptionsStore.setState({ subscriptions: [] }); + }, 60_000); + + it('gives the events it still carries what it changed, drops what it left out and writes nothing when unchanged', async () => { + await connectSingleton(); + const { useCalendarSubscriptionsStore } = await import('../../stores/calendar-subscriptions-store'); + const subs = useCalendarSubscriptionsStore.getState(); + feed = calendar( + vevent('a', 'talk', '20271206T090000Z', 'LOCATION:Room 1\r\n'), + vevent('b', 'workshop', '20271207T090000Z'), + vevent('w', 'weekly', '20271208T090000Z', 'RRULE:FREQ=WEEKLY;COUNT=3\r\n'), + ); + const sub = await subs.addSubscription({ name: `${RUN} feed`, url: FEED_URL }); + expect(sub.calendarId).toBeTruthy(); + const first = await mirrorEvents(sub.calendarId!); + expect(first.map((e) => e.title).sort()).toEqual([`${RUN} talk`, `${RUN} weekly`, `${RUN} workshop`]); + const talkId = first.find((e) => e.uid === `${RUN}-a`)!.id; + + // Unchanged: no write at all. + const jmap = await singleton(); + const spy = vi.spyOn(jmap, 'request'); + try { + await useCalendarSubscriptionsStore.getState().syncSubscription(sub.id); + const writes = spy.mock.calls.filter(([calls]) => + (calls as unknown as [string, Record][]).some(([name, args]) => + name === 'CalendarEvent/set' && (args.update || args.destroy || args.create))); + expect(writes).toEqual([]); + } finally { + spy.mockRestore(); + } + expect(useCalendarSubscriptionsStore.getState().subscriptions.find((s) => s.id === sub.id)?.lastError).toBeNull(); + + // Moved, renamed and in another room; one gone, one new, the series longer. + feed = calendar( + vevent('a', 'talk moved', '20271206T110000Z', 'LOCATION:Room 2\r\n'), + vevent('c', 'new talk', '20271209T090000Z'), + vevent('w', 'weekly', '20271208T090000Z', 'RRULE:FREQ=WEEKLY;COUNT=5\r\n'), + ); + await useCalendarSubscriptionsStore.getState().syncSubscription(sub.id); + expect(useCalendarSubscriptionsStore.getState().subscriptions.find((s) => s.id === sub.id)?.lastError).toBeNull(); + const second = await mirrorEvents(sub.calendarId!); + expect(second.map((e) => e.title).sort()).toEqual([`${RUN} new talk`, `${RUN} talk moved`, `${RUN} weekly`]); + const talk = second.find((e) => e.uid === `${RUN}-a`)!; + // The same event, changed in place. + expect(talk.id).toBe(talkId); + expect((await getEvents([talk.id]))[0].utcStart).toBe('2027-12-06T11:00:00Z'); + expect(Object.values(talk.locations ?? {}).map((l: any) => l.name)).toEqual(['Room 2']); + const weekly = second.find((e) => e.uid === `${RUN}-w`)!; + const rule = Array.isArray(weekly.recurrenceRule) ? weekly.recurrenceRule[0] : weekly.recurrenceRule; + expect(rule?.count).toBe(5); + + await useCalendarSubscriptionsStore.getState().removeSubscription(sub.id); + const [[, gone]] = await raw(client, [['Calendar/get', { accountId: client.accountId, ids: [sub.calendarId] }, '0']]); + expect(gone.notFound).toEqual([sub.calendarId]); + }, 90_000); + }); + describe('tasks', () => { it('lists the account\'s own tasks and none of another account\'s', async () => { const [[, created]] = await raw(client, [['CalendarEvent/set', { @@ -708,17 +905,58 @@ live('live Stalwart calendar', () => { await useCalendarStore.getState().fetchTasks(); expect(useCalendarStore.getState().tasks.map((t) => t.title)).toContain(`${RUN} task`); - if (!USER2) return; + if (!USER2 || !other) return; + const theirCalendar = (await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]))[0][1] + .list.find((c: { isDefault?: boolean }) => c.isDefault)?.id; + const [[, theirs]] = await raw(other, [['CalendarEvent/set', { + accountId: other.accountId, + create: { t: { '@type': 'Task', title: `${RUN} their task`, calendarIds: { [theirCalendar]: true }, due: '2027-11-02T10:00:00', timeZone: 'Europe/Berlin' } }, + }, '0']]); + expect(theirs.created?.t?.id, JSON.stringify(theirs.notCreated)).toBeTruthy(); + + // The first account's task load is still waiting for its tasks when + // the client switches to the second account and loads that one's. const second = new JMAPClient(); await second.connect(SERVER!, USER2, PASS); const jmap = await singleton(); - jmap.restoreSnapshot(second.snapshot()); + const request = jmap.request.bind(jmap); + let switched = false; + let waiting = false; + let release!: () => void; + const answers = new Promise((resolve) => { release = resolve; }); + const spy = vi.spyOn(jmap, 'request').mockImplementation(async (calls, using) => { + const readsTasks = (calls as unknown as [string, Record][]).some(([name, args]) => + name === 'CalendarEvent/get' && Array.isArray(args.properties) && args.properties.includes('alerts')); + if (switched || !readsTasks) return request(calls, using); + // Sent by the first account before the switch, answered after it. + waiting = true; + await answers; + return client.request(calls, using); + }); try { + useCalendarStore.getState().reset(); + const outgoing = useCalendarStore.getState().fetchTasks(); + await waitFor(async () => waiting, (w) => w, 10_000); + expect(waiting, 'the first account\'s tasks are being read').toBe(true); + + switched = true; + jmap.restoreSnapshot(second.snapshot()); useCalendarStore.getState().reset(); await useCalendarStore.getState().fetchCalendars(); await useCalendarStore.getState().fetchTasks(); - expect(useCalendarStore.getState().tasks.map((t) => t.title)).not.toContain(`${RUN} task`); + const loaded = useCalendarStore.getState().tasks; + expect(loaded.map((t) => t.title)).toContain(`${RUN} their task`); + release(); + await outgoing; + + // Untouched by the first account's load, which reads its task ids in + // the second account by then: the e2e accounts repeat each other's + // ids, so what that finds can even look right. + expect(useCalendarStore.getState().tasks).toBe(loaded); + expect(loaded.map((t) => t.title)).not.toContain(`${RUN} task`); } finally { + release(); + spy.mockRestore(); await connectSingleton(); } }, 60_000); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 1fcb97c6..d66e7471 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,9 +1,9 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; -import type { Calendar, CalendarEvent, CalendarRights } from './types'; +import type { Alert, Calendar, CalendarEvent, CalendarRights } from './types'; import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone'; -import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; +import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { RECURRENCE_BASE_PROPERTIES, SYNTHETIC_ID_PROBE, @@ -151,6 +151,9 @@ export function toLocalDateTime(value: string | Date, timeZone?: string): string // skipped for the rest of the session (the session lists every account with // the calendars capability, including ones that only share mail with us). const calendarAccessDenied = new Set(); +// One up with every reset (a new login): a refusal answered after one was +// given to the login before, whose shared accounts the next one may read. +let calendarAccessGeneration = 0; export function isCalendarAccessDenied(accountId: string): boolean { return calendarAccessDenied.has(accountId); @@ -158,6 +161,7 @@ export function isCalendarAccessDenied(accountId: string): boolean { export function resetCalendarAccessDenied(): void { calendarAccessDenied.clear(); + calendarAccessGeneration += 1; } // Stalwart 1.0 answers `accountNotSupportedByMethod` for an account that @@ -298,6 +302,18 @@ export function normalizeTaskProgress>(event: T : { ...event, progress: normalized }; } +/** + * A timed event without a duration lasts zero seconds (RFC 8984's default + * PT0S). Stalwart drops a DTEND equal to DTSTART and then ends such an event + * at 23:59:59 of its start day, so its utcEnd is wrong (#837). Tasks carry + * no duration and are left alone. Mirrors the webmail's withZeroDurationEnd. + */ +export function withZeroDurationEnd>(event: T): T { + if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) return event; + if (isTaskLikeObject(event as ScannedCalendarObject)) return event; + return { ...event, duration: 'PT0S', utcEnd: event.utcStart }; +} + /** * Convert the client's plural recurrence arrays to the singular JSCalendar 2.0 * properties Stalwart expects on write, dropping null rule fields. An empty @@ -432,6 +448,7 @@ const CALENDAR_PROPERTIES = [ export async function getCalendars(): Promise { const accountId = jmapClient.accountId; + const accessGeneration = calendarAccessGeneration; const probeKey = syntheticIdProbeKey(); const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey); const methodCalls: [string, Record, string][] = [ @@ -475,7 +492,7 @@ export async function getCalendars(): Promise { isShared: true, })); } catch (err) { - noteCalendarAccessError(sharedAccountId, err); + if (accessGeneration === calendarAccessGeneration) noteCalendarAccessError(sharedAccountId, err); return []; } }), @@ -965,7 +982,8 @@ export async function getEvents( options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES, { withOverrides: !options?.expanded, timeZone }, )); - return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e))); + return (list as unknown as CalendarEvent[]).map((e) => + withZeroDurationEnd(normalizeTaskProgress(normalizeRecurrenceProperties(e)))); } export type EventIdentityFields = Pick; @@ -992,6 +1010,30 @@ export async function findEventsByUid(uid: string, accountId?: string): Promise< 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, + accountId?: string, + limit = 100, +): Promise { + const account = accountId || jmapClient.accountId; + return withCalendarTimeZone(async (timeZone) => { + const args: Record = { + 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 // / reply / cancellation emails to participants. We pass it whenever an event // has participants so creating/updating/deleting a meeting notifies attendees. @@ -1203,8 +1245,9 @@ export async function createCalendar( name: string, color?: string, description?: string, + targetAccountId?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = targetAccountId || jmapClient.accountId; const props: Record = { name, color, isVisible: true, isSubscribed: true }; if (description?.trim()) props.description = description.trim(); const res = await jmapClient.request( @@ -1247,8 +1290,8 @@ export async function setDefaultCalendar( * The name of the user's calendar `id`, or null when the account has no * calendar by that id. */ -export async function getCalendarName(id: string): Promise { - const accountId = jmapClient.accountId; +export async function getCalendarName(id: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']], USING, @@ -1276,6 +1319,9 @@ export interface CalendarUpdates { description?: string | null; sortOrder?: number; isSubscribed?: boolean; + // `{}` clears them: Stalwart refuses null for these maps. + defaultAlertsWithTime?: Record; + defaultAlertsWithoutTime?: Record; } // Rename / recolour / describe a calendar (Calendar/set update). diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index 420cfe8a..60b65a0b 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -10,11 +10,22 @@ import { TouchableWithoutFeedback, View, } from 'react-native'; -import { Check } from '../icons'; -import type { Calendar } from '../../api/types'; +import { Check, Plus, X } from '../icons'; +import type { Alert, Calendar } from '../../api/types'; +import { + REMINDER_PRESETS, + alertsToReminders, + calendarDefaultAlerts, + defaultAlertIdPrefix, + formatReminder, + preservedAlerts, + remindersToDefaultAlerts, + sameReminders, + type Reminder, +} from '../../lib/calendar-alerts'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useLocaleStore } from '../../stores/locale-store'; +import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; import Button from '../Button'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; @@ -24,6 +35,79 @@ export interface CalendarEditValues { name: string; color: string; description: string; + /** Set only when the user changed them. */ + defaultAlertsWithTime?: Record; + defaultAlertsWithoutTime?: Record; +} + +/** One list of a calendar's default reminders: the times, a remove button each, presets to add. */ +function DefaultRemindersEditor({ + label, + reminders, + keptCount, + onChange, + styles, + c, + t, +}: { + label: string; + reminders: Reminder[]; + keptCount: number; + onChange: (next: Reminder[]) => void; + styles: ReturnType; + c: ThemePalette; + t: TranslateFn; +}) { + const [picking, setPicking] = React.useState(false); + const available = REMINDER_PRESETS.filter((p) => !reminders.some((r) => r.minutesBefore === p)); + return ( + + {label} + {reminders.length === 0 && keptCount === 0 && ( + {t('calendar.alerts.none', 'No reminders')} + )} + {reminders.map((r) => ( + + {formatReminder(r.minutesBefore, t)} + onChange(reminders.filter((x) => x.minutesBefore !== r.minutesBefore))} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('calendar.alerts.remove', 'Remove reminder')} + > + + + + ))} + {keptCount > 0 && ( + + {t('calendar.alerts.kept', "{count, plural, one {# more reminder that can't be edited here is kept} other {# more reminders that can't be edited here are kept}}", { count: keptCount })} + + )} + {available.length > 0 && ( + setPicking((v) => !v)} accessibilityRole="button"> + + {t('calendar.alerts.add', 'Add reminder')} + + )} + {picking && ( + + {available.map((preset) => ( + { + onChange([...reminders, { minutesBefore: preset }]); + setPicking(false); + }} + > + {formatReminder(preset, t)} + + ))} + + )} + + ); } interface CalendarEditSheetProps { @@ -47,15 +131,26 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend const [description, setDescription] = React.useState(''); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); + // The calendar's default reminders (#1209): the owner's to set, for an + // existing calendar. What the lists can't show is written back unchanged. + const editsDefaults = !!calendar && !calendar.isShared; + const initialTimed = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, false)), [calendar]); + const initialAllDay = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, true)), [calendar]); + const keptTimed = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, false)), [calendar]); + const keptAllDay = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, true)), [calendar]); + const [timedReminders, setTimedReminders] = React.useState([]); + const [allDayReminders, setAllDayReminders] = React.useState([]); React.useEffect(() => { if (!visible) return; setName(calendar?.name ?? ''); setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]); setDescription(calendar?.description ?? ''); + setTimedReminders(initialTimed); + setAllDayReminders(initialAllDay); setError(null); setSaving(false); - }, [visible, calendar]); + }, [visible, calendar, initialTimed, initialAllDay]); const handleSave = async () => { const trimmed = name.trim(); @@ -63,7 +158,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend setSaving(true); setError(null); try { - await onSave({ name: trimmed, color, description: description.trim() }); + const values: CalendarEditValues = { name: trimmed, color, description: description.trim() }; + // Ids unique in the account: Stalwart 1.0 refuses one another calendar uses. + if (calendar && editsDefaults && !sameReminders(timedReminders, initialTimed)) { + values.defaultAlertsWithTime = remindersToDefaultAlerts(timedReminders, keptTimed, defaultAlertIdPrefix(calendar, false)); + } + if (calendar && editsDefaults && !sameReminders(allDayReminders, initialAllDay)) { + values.defaultAlertsWithoutTime = remindersToDefaultAlerts(allDayReminders, keptAllDay, defaultAlertIdPrefix(calendar, true)); + } + await onSave(values); onClose(); } catch (e) { setError(e instanceof Error ? e.message : t('calendar.notifications.event_error', 'Something went wrong')); @@ -120,6 +223,35 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend style={[styles.input, styles.multiline]} multiline /> + {editsDefaults && ( + <> + {t('calendar.management.default_alerts', 'Default reminders')} + + {t( + 'calendar.management.default_alerts_hint', + "New events in this calendar get these reminders unless you change them in the event. Changing them here doesn't change existing events. Email reminders are sent by the mail server.", + )} + + + + + )} {error ? {error} : null} @@ -185,5 +317,32 @@ function makeStyles(c: ThemePalette) { swatchActive: { borderWidth: 2, borderColor: c.text }, error: { ...typography.caption, color: c.error }, footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md }, + sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md }, + muted: { ...typography.caption, color: c.textMuted }, + reminders: { gap: spacing.xs }, + reminderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 36, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + reminderText: { ...typography.body, color: c.text }, + addRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.xs }, + addRowText: { ...typography.bodyMedium, color: c.primary }, + presets: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, + preset: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + presetText: { ...typography.caption, color: c.text }, }); } diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx new file mode 100644 index 00000000..67671056 --- /dev/null +++ b/src/components/calendar/CalendarSearchSheet.tsx @@ -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(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(() => { + 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 ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => choose(event)} + accessibilityRole="button" + accessibilityLabel={title} + > + + + {format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })} + + + {event.showWithoutTime + ? t('calendar.events.all_day', 'All day') + : `${format(start, time, { locale })} – ${format(end, time, { locale })}`} + + + + + {title} + {(meta || repeats) ? ( + + {place ? : null} + {meta ? {meta} : null} + {repeats && ( + <> + + {t('calendar.search.repeats', 'Repeats')} + + )} + + ) : null} + + + ); + }; + + return ( + { + const event = pending.current; + pending.current = null; + if (event) onSelectEvent(event); + }} + > + + + + + + + + + {query ? ( + setQuery('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('calendar.search.clear', 'Clear search')} + > + + + ) : null} + + + + {searching && rows.length === 0 ? ( + + ) : results && rows.length === 0 ? ( + + + {t('calendar.search.no_results', 'No events match “{query}”', { query: query.trim() })} + + + {t('calendar.search.no_results_hint', 'Search looks at titles, places, descriptions and guests.')} + + + ) : ( + row.key} + keyboardShouldPersistTaps="handled" + automaticallyAdjustKeyboardInsets + ListHeaderComponent={searching ? : null} + renderItem={({ item }) => (item.kind === 'header' + ? {item.label} + : renderEvent(item.event))} + /> + )} + + + ); +} + +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 }, + }); +} diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 61e41185..bce7fbba 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -8,8 +8,9 @@ import { StyleSheet, Platform, Alert as NativeAlert, + type LayoutChangeEvent, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import DateTimePicker from '@react-native-community/datetimepicker'; import { X, @@ -49,8 +50,10 @@ import { useCalendarLocale } from '../../lib/calendar-locale'; import { formatCalendarDate } from '../../lib/date-format'; import { alertsToReminders, + calendarDefaultAlerts, reminderFieldsToSave, preservedAlerts, + withOwnAlertIds, formatReminder, REMINDER_PRESETS, REMINDER_UNIT_MINUTES, @@ -72,6 +75,8 @@ import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { RecurrenceEditor } from './RecurrenceEditor'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { SafeAreaModal } from '../SafeAreaModal'; +import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; +import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import ToggleSwitch from '../ToggleSwitch'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -185,6 +190,25 @@ export function EventModal({ const isEdit = !!event; const resolveContactName = useContactNameResolver(); + // The keyboard covered the bottom fields (location, description; #81). + // iOS: the scroll view insets itself and scrolls the focused field into + // view (automaticallyAdjustKeyboardInsets). Android: the edge-to-edge + // dialog does not resize for the keyboard, so the scroll view ends above + // it, and Android's ScrollView brings the focused field back into view + // when it shrinks. + const insets = useSafeAreaInsets(); + const keyboard = useKeyboardObstruction(insets.bottom); + const fullHeight = React.useRef(0); + const [containerHeight, setContainerHeight] = React.useState(0); + const onContainerLayout = (e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + if (keyboard === 0) fullHeight.current = height; + setContainerHeight(height); + }; + const keyboardInset = Platform.OS === 'android' + ? editorKeyboardInset(keyboard, insets.bottom, fullHeight.current - containerHeight) + : 0; + const [title, setTitle] = React.useState(''); const [description, setDescription] = React.useState(''); const [allDay, setAllDay] = React.useState(false); @@ -206,6 +230,10 @@ export function EventModal({ const [customRule, setCustomRule] = React.useState(null); const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false); const [reminders, setReminders] = React.useState([]); + // A new event starts on its calendar's default reminders (#1209) until + // the user customizes them. + const [useDefaults, setUseDefaults] = React.useState(false); + const onDefaults = !event && useDefaults; // Alerts the picker can't show (absolute / end-relative / non-display) // ride along untouched so a save never drops them. const [preserved, setPreserved] = React.useState>({}); @@ -246,6 +274,7 @@ export function EventModal({ setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null); setRecurrenceEditorOpen(false); setReminders(alertsToReminders(event.alerts)); + setUseDefaults(false); setPreserved(preservedAlerts(event.alerts)); setSendInvitations(true); setCustomReminderOpen(false); @@ -264,6 +293,7 @@ export function EventModal({ setCustomRule(null); setRecurrenceEditorOpen(false); setReminders([]); + setUseDefaults(true); setPreserved({}); setSendInvitations(true); setCustomReminderOpen(false); @@ -347,7 +377,11 @@ export function EventModal({ recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'), // Untouched reminders are not sent: an event on its calendar's // default alerts keeps them. - ...reminderFieldsToSave(event, reminders, preserved, defaultAlertIds), + // A new event on its calendar's defaults: the server copies them + // into it (sending them as alerts too would double them). + ...(onDefaults + ? { useDefaultAlerts: true } + : reminderFieldsToSave(event, reminders, preserved, defaultAlertIds)), locations: clearedOr(buildLocations(location), 'locations'), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'), }; @@ -433,6 +467,18 @@ export function EventModal({ const availablePresets = REMINDER_PRESETS.filter( (p) => !reminders.some((r) => r.minutesBefore === p), ); + // The selected calendar's defaults, for an event with a time or all day. + const calendarDefaults = calendarDefaultAlerts(selectedCalendar, allDay); + const defaultReminders = alertsToReminders(calendarDefaults); + // Start the event's own reminders from the defaults, to change them. + const customizeReminders = () => { + setReminders(defaultReminders); + // Under ids of their own: Stalwart 1.0 drops an event's alert under a + // default alert's id. + const kept = preservedAlerts(calendarDefaults); + setPreserved(withOwnAlertIds(Object.values(kept), Object.keys(calendarDefaults))); + setUseDefaults(false); + }; return ( - + 0 && { marginBottom: keyboardInset }]} contentContainerStyle={styles.bodyContent} keyboardShouldPersistTaps="handled" + automaticallyAdjustKeyboardInsets >
} label={t('calendar.alerts.title', 'Reminders')}> - {reminders.length > 0 && ( + {onDefaults && ( + + {t('calendar.alerts.calendar_defaults', 'Calendar defaults')} + {defaultReminders.length === 0 && ( + {t('calendar.alerts.none', 'No reminders')} + )} + {defaultReminders.map((r) => ( + + {formatReminder(r.minutesBefore, t)} + + ))} + + {t('calendar.alerts.customize', 'Customize')} + + + )} + {!onDefaults && !event && ( + { + setUseDefaults(true); + setReminderPickerOpen(false); + setCustomReminderOpen(false); + }} + accessibilityRole="button" + > + {t('calendar.alerts.use_defaults', 'Use calendar defaults')} + + )} + {!onDefaults && reminders.length > 0 && ( {reminders.map((r) => ( @@ -649,7 +725,7 @@ export function EventModal({ ))} )} - {availablePresets.length > 0 && ( + {!onDefaults && availablePresets.length > 0 && ( setReminderPickerOpen((v) => !v)} @@ -723,7 +799,7 @@ export function EventModal({ onChangeText={setLocation} placeholder={t('calendar.form.location_placeholder', 'Place or address')} placeholderTextColor={c.mutedForeground} - style={styles.fieldButton} + style={styles.fieldInput} />
@@ -736,7 +812,7 @@ export function EventModal({ autoCapitalize="none" autoCorrect={false} keyboardType="url" - style={styles.fieldButton} + style={styles.fieldInput} /> @@ -944,6 +1020,19 @@ function makeStyles(c: ThemePalette) { justifyContent: 'center', }, fieldText: { ...typography.body, color: c.text }, + // A text field in the field frame. Without a colour of its own a + // TextInput draws black, invisible on the dark background (#82). + fieldInput: { + minHeight: 40, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + ...typography.body, + color: c.text, + }, limitRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.xs }, limitText: { ...typography.caption, color: c.warning, flex: 1 }, @@ -999,6 +1088,7 @@ function makeStyles(c: ThemePalette) { paddingVertical: spacing.sm, }, addRowText: { ...typography.body, color: c.primary }, + defaultsCaption: { ...typography.caption, color: c.textMuted }, popover: { borderWidth: 1, diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 9baaaddd..52c94a1d 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -5,7 +5,7 @@ import { CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle, ShieldCheck, ShieldAlert, ChevronDown, } from '../icons'; -import { format, parseISO } from 'date-fns'; +import { format, isSameDay } from 'date-fns'; import type { Calendar, Email, CalendarEvent } from '../../api/types'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -17,9 +17,11 @@ import { calendarInvitationKey, findCalendarAttachment, findParticipantByEmail, + getInvitationDisplayRange, getInvitationMethod, getInvitationTrustAssessment, getOrganizerName, + invitationZoneLabel, buildReplyTo, type InvitationMethod, type InvitationTrustAssessment, @@ -65,7 +67,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); - const dateLocale = getDateFnsLocale(useLocaleStore((s) => s.locale)); + const locale = useLocaleStore((s) => s.locale); + const dateLocale = getDateFnsLocale(locale); const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); const calendars = useCalendarStore((s) => s.calendars); @@ -156,8 +159,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { }, [storeEvents, event?.uid, serverMatch]); const trust = React.useMemo( - () => (event ? getInvitationTrustAssessment(event, email, method) : null), - [event, email, method], + () => (event ? getInvitationTrustAssessment(event, email, method, currentUserEmails) : null), + [event, email, method, currentUserEmails], ); if (!attachment || !enabled || state === 'error') return null; @@ -172,13 +175,25 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { } if (!event) return null; - const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start); - const startDate = startStr ? parseISO(startStr) : null; - const dateLabel = startDate && !isNaN(startDate.getTime()) - ? (event.showWithoutTime - ? format(startDate, 'EEEE, MMM d, yyyy', { locale: dateLocale }) - : format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale: dateLocale })) - : null; + // In the user's zone, with its name, for an event at a fixed moment; the + // organizer's wall clock otherwise read as a device time (webmail #1252). + const range = getInvitationDisplayRange(event); + let dateLabel: string | null = null; + if (range) { + const time = timePattern(timeFormat); + if (event.showWithoutTime) { + const day = 'EEEE, MMM d, yyyy'; + dateLabel = format(range.start, day, { locale: dateLocale }) + + (range.end ? ` – ${format(range.end, day, { locale: dateLocale })}` : ''); + } else { + dateLabel = format(range.start, `EEE, MMM d · ${time}`, { locale: dateLocale }); + if (range.end) { + dateLabel += ` – ${format(range.end, isSameDay(range.start, range.end) ? time : `EEE, MMM d · ${time}`, { locale: dateLocale })}`; + } + const zone = range.instant ? invitationZoneLabel(range.instant, locale) : null; + if (zone) dateLabel += ` ${zone}`; + } + } const organizer = getOrganizerName(event); const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; diff --git a/src/lib/__tests__/calendar-alert-scheduler.test.ts b/src/lib/__tests__/calendar-alert-scheduler.test.ts index 6147cf08..9a5c7864 100644 --- a/src/lib/__tests__/calendar-alert-scheduler.test.ts +++ b/src/lib/__tests__/calendar-alert-scheduler.test.ts @@ -49,8 +49,8 @@ describe('parseAlertOffset / computeFireTime', () => { ); }); - it('fires task alerts relative to due', () => { - const task = { ...event, due: '2026-03-02T09:00:00', alerts: undefined }; + it('fires the alerts of a task without a start relative to its due', () => { + const task = { ...event, start: undefined as unknown as string, utcStart: undefined, due: '2026-03-02T09:00:00', alerts: undefined }; expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe( new Date('2026-03-02T09:00:00').getTime() - HOUR, ); @@ -58,11 +58,32 @@ describe('parseAlertOffset / computeFireTime', () => { }); it('reads a zoned task due in its own time zone', () => { - const task = { ...event, due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin', alerts: undefined }; + const task = { ...event, start: undefined as unknown as string, utcStart: undefined, due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin', alerts: undefined }; expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe( new Date('2026-07-01T14:00:00Z').getTime(), ); }); + + // #83 (webmail #1328): start 20:00, due 20:15, five minutes before the start. + it('fires a task alert relative to its start from the start, and one relative to its end from the due', () => { + const task = { + ...event, start: '2026-07-01T20:00:00', utcStart: undefined, due: '2026-07-01T20:15:00', timeZone: 'Europe/Berlin', alerts: undefined, + }; + expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' })).toBe( + new Date('2026-07-01T17:55:00Z').getTime(), + ); + // relativeTo left out means the start. + expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M' })).toBe( + new Date('2026-07-01T17:55:00Z').getTime(), + ); + expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' })).toBe( + new Date('2026-07-01T18:10:00Z').getTime(), + ); + // The server's UTC start, when it gave one. + expect(computeTaskFireTime({ ...task, utcStart: '2026-07-01T18:00:00Z' }, { '@type': 'OffsetTrigger', offset: '-PT5M' })).toBe( + new Date('2026-07-01T17:55:00Z').getTime(), + ); + }); }); describe('getEffectiveAlerts', () => { @@ -91,6 +112,8 @@ describe('getUpcomingAlerts', () => { const task = { ...event, id: 't1', + start: undefined as unknown as string, + utcStart: undefined, due: '2026-03-01T12:00:00Z', alerts: { x: { trigger: { '@type': 'OffsetTrigger', offset: 'PT0S' }, action: 'display' } }, }; @@ -109,6 +132,8 @@ describe('getUpcomingAlerts', () => { const task = { ...untitled, id: 't1', + start: undefined as unknown as string, + utcStart: undefined, due: '2026-03-01T12:00:00Z', alerts: { x: { trigger: { '@type': 'OffsetTrigger' as const, offset: 'PT0S' }, action: 'display' as const } }, }; diff --git a/src/lib/__tests__/calendar-alerts.test.ts b/src/lib/__tests__/calendar-alerts.test.ts index 3d13c640..624255d8 100644 --- a/src/lib/__tests__/calendar-alerts.test.ts +++ b/src/lib/__tests__/calendar-alerts.test.ts @@ -8,6 +8,10 @@ import { preservedAlerts, reminderFieldsToSave, withOwnAlertIds, + calendarDefaultAlerts, + defaultAlertIdPrefix, + remindersToDefaultAlerts, + sameReminders, } from '../calendar-alerts'; import { translate, type MessageParams } from '../../i18n'; import type { Alert } from '../../api/types'; @@ -164,3 +168,41 @@ describe('withOwnAlertIds', () => { expect(Object.keys(withOwnAlertIds([alert, alert], ['reminder-1', 'reminder-3']))).toEqual(['reminder-2', 'reminder-4']); }); }); + +describe('a calendar\'s default reminders (#1209)', () => { + const email = { '@type': 'Alert', action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } as Alert; + + it('picks the defaults for timed or all-day events', () => { + const cal = { + defaultAlertsWithTime: { a: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } as Alert }, + defaultAlertsWithoutTime: null, + }; + expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]); + expect(calendarDefaultAlerts(cal, true)).toEqual({}); + expect(calendarDefaultAlerts(undefined, false)).toEqual({}); + }); + + it('writes the defaults under ids of their own and keeps what the list cannot show', () => { + const alerts = remindersToDefaultAlerts([{ minutesBefore: 10 }, { minutesBefore: 60 * 24 }], { 'default-1': email }); + expect(alerts['default-1']).toBe(email); + expect(Object.keys(alerts).sort()).toEqual(['default-1', 'default-2', 'default-3']); + expect(alertsToReminders(alerts)).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 60 }, { minutesBefore: 10 }]); + // Emptied: a map Stalwart takes, not null. + expect(remindersToDefaultAlerts([])).toEqual({}); + }); + + it('gives each calendar and list ids no other calendar of the account uses (Stalwart 1.0)', () => { + const b = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, false)); + const c = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'acc-2:c', originalId: 'c' }, false)); + const bAllDay = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, true)); + const ids = [...Object.keys(b), ...Object.keys(c), ...Object.keys(bAllDay)]; + expect(new Set(ids).size).toBe(3); + expect(Object.keys(b)).toEqual(['cal-b-timed-1']); + }); + + it('tells a changed list from the same times in another order', () => { + expect(sameReminders([{ minutesBefore: 5 }, { minutesBefore: 15 }], [{ minutesBefore: 15 }, { minutesBefore: 5 }])).toBe(true); + expect(sameReminders([{ minutesBefore: 5 }], [{ minutesBefore: 10 }])).toBe(false); + expect(sameReminders([], [{ minutesBefore: 10 }])).toBe(false); + }); +}); diff --git a/src/lib/__tests__/calendar-editor-keyboard.test.ts b/src/lib/__tests__/calendar-editor-keyboard.test.ts new file mode 100644 index 00000000..a68af473 --- /dev/null +++ b/src/lib/__tests__/calendar-editor-keyboard.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { editorKeyboardInset } from '../calendar-editor-keyboard'; + +describe('editorKeyboardInset (#81)', () => { + it('ends the scroll area above the keyboard, past the safe-area inset already kept clear', () => { + expect(editorKeyboardInset(320, 24, 0)).toBe(296); + }); + + it('shortens a window that resized for the keyboard by nothing more', () => { + expect(editorKeyboardInset(320, 24, 296)).toBe(0); + expect(editorKeyboardInset(320, 24, 400)).toBe(0); + }); + + it('keeps the full height without a keyboard', () => { + expect(editorKeyboardInset(0, 24, 0)).toBe(0); + }); +}); diff --git a/src/lib/__tests__/calendar-feed-diff.test.ts b/src/lib/__tests__/calendar-feed-diff.test.ts new file mode 100644 index 00000000..d78e4da2 --- /dev/null +++ b/src/lib/__tests__/calendar-feed-diff.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { feedChanged, feedFields } from '../calendar-feed-diff'; + +// What CalendarEvent/parse gives for a feed, and what Stalwart 0.16 and 1.0 +// give back for the same events once imported (the webmail's +// calendar-subscription-refresh test, from both servers). +const PARSED: Partial[] = [ + { uid: 'e1', title: 'Timed Berlin', description: 'Line one\nLine two', start: '2026-11-10T09:00:00', duration: 'PT1H30M', timeZone: 'Europe/Berlin', status: 'confirmed', locations: { '5960afaa': { '@type': 'Location', name: 'Room 1' } } as never }, + { uid: 'e3', title: 'All day', start: '2026-11-12T00:00:00', duration: 'P1D', showWithoutTime: true }, + { + uid: 'e5', title: 'Weekly', start: '2026-11-02T08:00:00', duration: 'PT45M', timeZone: 'Europe/Berlin', + recurrenceRules: [{ frequency: 'weekly', count: 6, byDay: [{ day: 'mo' }] }] as never, + recurrenceOverrides: { + '2026-11-09T08:00:00': { excluded: true }, + '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', updated: '2026-10-20T10:00:00Z', duration: 'PT45M', title: 'Weekly moved' }, + } as never, + }, + { uid: 'e7', title: 'Zero length', description: 'Zero', start: '2026-11-19T12:00:00', timeZone: 'Etc/UTC' }, +]; + +function stored(version: '0.16' | '1.0'): Partial[] { + return PARSED.map((p) => { + const event = { ...p, id: `id-${p.uid}`, calendarIds: { sub: true }, useDefaultAlerts: false } as Partial; + if (p.uid === 'e7') event.duration = 'PT0S'; + if (p.uid === 'e5') { + event.recurrenceRules = [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 6, interval: 1, byDay: [{ '@type': 'NDay', day: 'mo' }] }] as never; + event.recurrenceOverrides = (version === '0.16' + // 0.16: its own `updated`, keys in another order. + ? { '2026-11-09T08:00:00': { excluded: true }, '2026-11-16T08:00:00': { title: 'Weekly moved', updated: '2026-10-26T13:31:01Z', duration: 'PT45M', start: '2026-11-16T10:00:00' } } + // 1.0: only what differs from the series. + : { '2026-11-09T08:00:00': { excluded: true }, '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved' } }) as never; + } + return event; + }); +} + +describe('feedChanged', () => { + it.each(['0.16', '1.0'] as const)('finds nothing changed in a feed imported unchanged (%s)', (version) => { + const back = stored(version); + PARSED.forEach((p, i) => expect(feedChanged(p, back[i]), p.uid).toBe(false)); + }); + + it.each(['0.16', '1.0'] as const)('sees a moved event, a new place, a longer series and a moved occurrence (%s)', (version) => { + const back = stored(version); + expect(feedChanged({ ...PARSED[0], start: '2026-11-10T11:00:00' }, back[0])).toBe(true); + expect(feedChanged({ ...PARSED[0], locations: { x: { '@type': 'Location', name: 'Room 2' } } as never }, back[0])).toBe(true); + expect(feedChanged({ ...PARSED[2], recurrenceRules: [{ frequency: 'weekly', count: 8, byDay: [{ day: 'mo' }] }] as never }, back[2])).toBe(true); + expect(feedChanged({ + ...PARSED[2], + recurrenceOverrides: { ...PARSED[2].recurrenceOverrides, '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved again' } } as never, + }, back[2])).toBe(true); + expect(feedChanged({ ...PARSED[1], title: 'All day renamed' }, back[1])).toBe(true); + }); +}); + +describe('feedFields', () => { + it('writes what the feed decides and leaves the alerts alone', () => { + const fields = feedFields({ ...PARSED[3], alerts: { a: { '@type': 'Alert' } } as never }); + expect(fields).toMatchObject({ title: 'Zero length', duration: 'PT0S', timeZone: 'Etc/UTC', status: 'confirmed', locations: null }); + expect(fields).not.toHaveProperty('alerts'); + expect(fields).not.toHaveProperty('excludedRecurrenceRules'); + expect(feedFields(PARSED[1])).toMatchObject({ start: '2026-11-12T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }); + }); +}); diff --git a/src/lib/__tests__/calendar-ics-export.test.ts b/src/lib/__tests__/calendar-ics-export.test.ts index 9bde1dbc..ab13137e 100644 --- a/src/lib/__tests__/calendar-ics-export.test.ts +++ b/src/lib/__tests__/calendar-ics-export.test.ts @@ -48,11 +48,38 @@ describe('eventToICS', () => { expect(ics).not.toContain('DTEND'); }); - it('folds long lines at 74 octets', () => { + it('folds long lines at 75 octets', () => { const ics = eventToICS({ ...event, description: 'x'.repeat(200) }); const long = ics.split('\r\n').filter((l) => l.length > 75); expect(long).toHaveLength(0); }); + + it('folds by octets between characters, so umlauts and emoji survive', () => { + const utf8 = new TextEncoder(); + const text = 'Grüße 🎉 '.repeat(30); + const ics = eventToICS({ ...event, description: text }); + const lines = ics.split('\r\n'); + expect(lines.every((l) => utf8.encode(l).length <= 75)).toBe(true); + // Unfolded, the description reads back whole: no emoji cut in half. + const unfolded = ics.replace(/\r\n /g, ''); + expect(unfolded).toContain(`DESCRIPTION:${text}`); + expect(unfolded).not.toContain('�'); + expect(lines.some((l) => /[\uD800-\uDBFF]$/.test(l))).toBe(false); + }); + + it('quotes a name holding a colon, semicolon or comma, and writes no blank one', () => { + const ics = eventToICS({ + ...event, + participants: { + a: { calendarAddress: 'mailto:alice@example.com', name: 'Ops: Alice' }, + b: { calendarAddress: 'mailto:bob@example.com', name: 'Smith, Bob "BB"', roles: { attendee: true } }, + c: { calendarAddress: 'mailto:carol@example.com', name: ' ', roles: { attendee: true } }, + }, + }); + expect(ics).toContain('ORGANIZER;CN="Ops: Alice":mailto:alice@example.com'); + expect(ics).toContain('ATTENDEE;CN="Smith, Bob \'BB\'";PARTSTAT=NEEDS-ACTION:mailto:bob@example.com'); + expect(ics).toContain('ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:carol@example.com'); + }); }); describe('helpers', () => { diff --git a/src/lib/__tests__/calendar-invitation.test.ts b/src/lib/__tests__/calendar-invitation.test.ts index ab1b65c7..c3cffb2e 100644 --- a/src/lib/__tests__/calendar-invitation.test.ts +++ b/src/lib/__tests__/calendar-invitation.test.ts @@ -1,16 +1,57 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; +import { format } from 'date-fns'; import type { Email } from '../../api/types'; +import { useSettingsStore } from '../../stores/settings-store'; import { calendarInvitationKey, extractMethodFromContentType, extractMethodFromRawIcs, findCalendarAttachment, getEmailAuthenticationResults, + getInvitationDisplayRange, getInvitationMethod, getInvitationTrustAssessment, + invitationZoneLabel, parseAuthenticationResults, } from '../calendar-invitation'; +describe('getInvitationDisplayRange (webmail #1252)', () => { + afterEach(() => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + }); + const shown = (d: Date | null | undefined) => (d ? format(d, 'yyyy-MM-dd HH:mm') : null); + + it('shows an organizer\'s wall clock in the user\'s zone', () => { + useSettingsStore.setState({ calendarTimeZone: 'Europe/Warsaw' }); + // As CalendarEvent/parse gives an Exchange invitation from London: no utcStart. + const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', duration: 'PT25M' }); + expect(shown(range?.start)).toBe('2026-10-20 16:00'); + expect(shown(range?.end)).toBe('2026-10-20 16:25'); + expect(range?.instant?.toISOString()).toBe('2026-10-20T14:00:00.000Z'); + expect(invitationZoneLabel(range!.instant!, 'en')).toBeTruthy(); + }); + + it('takes the server\'s UTC start when it has one', () => { + useSettingsStore.setState({ calendarTimeZone: 'America/New_York' }); + const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', utcStart: '2026-10-20T14:00:00Z', utcEnd: '2026-10-20T15:00:00Z' }); + expect(shown(range?.start)).toBe('2026-10-20 10:00'); + expect(shown(range?.end)).toBe('2026-10-20 11:00'); + }); + + it('leaves floating times and all-day dates as written', () => { + useSettingsStore.setState({ calendarTimeZone: 'America/New_York' }); + const floating = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', duration: 'PT1H' }); + expect(shown(floating?.start)).toBe('2026-10-20 15:00'); + expect(floating?.instant).toBeUndefined(); + const oneDay = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P1D', showWithoutTime: true }); + expect(shown(oneDay?.start)).toBe('2026-10-20 00:00'); + expect(oneDay?.end).toBeNull(); + // The stored end is exclusive: three days end on the third. + const threeDays = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P3D', showWithoutTime: true }); + expect(shown(threeDays?.end)).toBe('2026-10-22 00:00'); + }); +}); + const request = { organizerCalendarAddress: 'mailto:alice@example.com', participants: { @@ -63,7 +104,7 @@ describe('authentication results', () => { 'mx.example.com; dkim=pass header.d=example.com header.s=sel; spf=pass smtp.mailfrom=example.com; dmarc=pass header.from=example.com', ); expect(parsed.spf?.result).toBe('pass'); - expect(parsed.dkim).toEqual({ result: 'pass', domain: 'example.com', selector: 'sel' }); + expect(parsed.dkim).toMatchObject({ result: 'pass', domain: 'example.com', selector: 'sel' }); expect(parsed.dmarc?.result).toBe('pass'); }); @@ -77,6 +118,26 @@ describe('authentication results', () => { expect(getEmailAuthenticationResults(e)?.dmarc?.result).toBe('fail'); expect(getEmailAuthenticationResults(email({}))).toBeNull(); }); + + // A sender controls the envelope address, comments and every header below + // the receiving server's own. + it('takes no result a sender wrote into a property, a comment or a lower header', () => { + const inEnvelope = parseAuthenticationResults( + 'mx.example.org; spf=none smtp.mailfrom="dkim=pass;dmarc=pass"@forger.example (dkim=pass header.d=example.com)', + ); + expect(inEnvelope.dkim).toBeUndefined(); + expect(inEnvelope.dmarc).toBeUndefined(); + const lower = email({ + from: [{ email: 'alice@example.com' }], + headers: [ + { name: 'Authentication-Results', value: 'mx.example.org; spf=none smtp.mailfrom=forger.example; dmarc=none' }, + { name: 'Authentication-Results', value: 'forged; dkim=pass header.d=example.com; dmarc=pass header.from=example.com' }, + ], + }); + expect(getEmailAuthenticationResults(lower)).toMatchObject({ dmarc: { result: 'none' } }); + expect(getEmailAuthenticationResults(lower)?.dkim).toBeUndefined(); + expect(getInvitationTrustAssessment(request, lower, 'request').level).toBe('caution'); + }); }); describe('getInvitationTrustAssessment', () => { @@ -113,6 +174,51 @@ describe('getInvitationTrustAssessment', () => { expect(getInvitationTrustAssessment(request, e, 'request').reason).toBe('authentication_missing'); expect(getInvitationTrustAssessment(request, e, 'unknown').level).toBe('trusted'); }); + + // #1374: a forger passes SPF for an envelope domain of their own, or signs + // with their own key, and writes the organizer's address in From. + const forged = (results: string) => email({ + from: [{ email: 'alice@example.com' }], + headers: [{ name: 'Authentication-Results', value: `mx.example.org; ${results}` }], + }); + + it('trusts only a pass aligned with the From domain', () => { + expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.mailfrom=bounce@forger.example; dmarc=none header.from=example.com'), 'request')) + .toMatchObject({ level: 'caution', reason: 'authentication_missing' }); + expect(getInvitationTrustAssessment(request, forged('dkim=pass header.d=forger.example header.s=k1; dmarc=none'), 'request').level) + .toBe('caution'); + // A HELO pass never vouches for the sender. + expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.helo=example.com'), 'request').level).toBe('caution'); + + // Aligned: the same domain, or a parent or child of it. + expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.mailfrom=bounce@mail.example.com; dmarc=none'), 'request').level) + .toBe('trusted'); + expect(getInvitationTrustAssessment(request, forged('dkim=pass header.d=forger.example; dkim=pass header.d=example.com'), 'request').level) + .toBe('trusted'); + }); + + it('warns about a forged organizer whose mail passes only for the forger\'s domain', () => { + const e = email({ + from: [{ email: 'mallory@forger.example' }], + headers: [{ name: 'Authentication-Results', value: 'mx.example.org; spf=pass smtp.mailfrom=forger.example; dmarc=none' }], + }); + // Aligned with its own From, so only a caution about the mismatch. + expect(getInvitationTrustAssessment(request, e, 'request')).toMatchObject({ level: 'caution', reason: 'sender_mismatch' }); + const unaligned = email({ + from: [{ email: 'mallory@forger.example' }], + headers: [{ name: 'Authentication-Results', value: 'mx.example.org; spf=pass smtp.mailfrom=other.example; dmarc=none' }], + }); + expect(getInvitationTrustAssessment(request, unaligned, 'request')).toMatchObject({ level: 'warning', reason: 'sender_mismatch_unverified' }); + }); + + it('does not flag a colleague\'s invitation that never left the server', () => { + const colleague = email({ from: [{ email: 'alice@example.com' }] }); + expect(getInvitationTrustAssessment(request, colleague, 'request', ['bob@example.com']).level).toBe('trusted'); + // From outside, or with a header saying it came in from outside: as before. + expect(getInvitationTrustAssessment(request, colleague, 'request', ['bob@elsewhere.example']).reason).toBe('authentication_missing'); + expect(getInvitationTrustAssessment(request, forged('spf=none smtp.mailfrom=example.com'), 'request', ['bob@example.com']).reason) + .toBe('authentication_missing'); + }); }); describe('calendarInvitationKey', () => { diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts index d7d56889..0ed5301c 100644 --- a/src/lib/__tests__/calendar-participants.test.ts +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -349,6 +349,17 @@ describe('answers on save (Stalwart 0.16 and 1.0)', () => { ]); }); + it('writes no blank names', () => { + const map = buildParticipantMap({ name: '', email: 'me@example.com' }, [ + { name: ' ', email: 'bob@example.com' }, + { name: ' Carol ', email: 'carol@example.com' }, + ]); + const byEmail = Object.fromEntries(Object.values(map).map((p) => [p.email, p])); + expect(byEmail['me@example.com']).not.toHaveProperty('name'); + expect(byEmail['bob@example.com']).not.toHaveProperty('name'); + expect(byEmail['carol@example.com'].name).toBe('Carol'); + }); + it('tells whether anyone but the organizer answered', () => { expect(hasAnsweredAttendees(meeting.participants)).toBe(true); expect(hasAnsweredAttendees(participantsAskedAgain(meeting))).toBe(false); diff --git a/src/lib/__tests__/calendar-search.test.ts b/src/lib/__tests__/calendar-search.test.ts new file mode 100644 index 00000000..b94844c8 --- /dev/null +++ b/src/lib/__tests__/calendar-search.test.ts @@ -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 => ({ + 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']); + }); +}); diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts index 054db5f5..088a8626 100644 --- a/src/lib/__tests__/calendar-utils.test.ts +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -332,6 +332,7 @@ describe('getTimedEventBoundsForDay on DST change days', () => { timeZone: 'Europe/Berlin', utcStart: '2026-10-25T11:51:00Z', utcEnd: '2026-10-25T12:16:00Z', + duration: 'PT25M', }); expect(getTimedEventBoundsForDay(fallBack, new Date(2026, 9, 25))).toMatchObject({ startMinutes: 12 * 60 + 51, @@ -344,6 +345,7 @@ describe('getTimedEventBoundsForDay on DST change days', () => { timeZone: 'Europe/Berlin', utcStart: '2026-03-29T07:00:00Z', utcEnd: '2026-03-29T08:00:00Z', + duration: 'PT1H', }); expect(getTimedEventBoundsForDay(springForward, new Date(2026, 2, 29))).toMatchObject({ startMinutes: 9 * 60, @@ -354,3 +356,13 @@ describe('getTimedEventBoundsForDay on DST change days', () => { } }); }); + +describe('getEventEndDate', () => { + // #837: Stalwart stores a DTEND equal to DTSTART as no duration and ends + // the event at 23:59:59 of its day. + it('ends a timed event without a duration at its start, whatever utcEnd says', () => { + const zero = ev({ start: '2026-03-02T09:00:00', utcStart: '2026-03-02T09:00:00Z', utcEnd: '2026-03-02T23:59:59Z' }); + delete (zero as Partial).duration; + expect(getEventEndDate(zero).getTime()).toBe(getEventStartDate(zero).getTime()); + }); +}); diff --git a/src/lib/__tests__/recurrence-dst-gap.test.ts b/src/lib/__tests__/recurrence-dst-gap.test.ts new file mode 100644 index 00000000..e568091e --- /dev/null +++ b/src/lib/__tests__/recurrence-dst-gap.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { expandRecurringEvents } from '../recurrence-expansion'; + +// Daily expansion advanced a Date across the device's DST gap and kept the +// shifted time (02:30 became 03:30, midnight became 01:00); the implicit +// byHour then rejected every later day, so the series ended at the gap. + +function daily(start: string, allDay: boolean, timeZone?: string): CalendarEvent { + return { + id: 'E1', uid: 'u1', title: 'Daily', calendarIds: { c: true }, + start, duration: allDay ? 'P1D' : 'PT30M', showWithoutTime: allDay, + ...(timeZone ? { timeZone } : {}), + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }], + } as unknown as CalendarEvent; +} + +let originalTZ: string | undefined; +beforeEach(() => { + originalTZ = process.env.TZ; +}); +afterEach(() => { + process.env.TZ = originalTZ; +}); + +describe('daily recurrence across a DST gap', () => { + it('keeps a 02:30 series going past the spring-forward night (Berlin)', () => { + process.env.TZ = 'Europe/Berlin'; + const out = expandRecurringEvents([daily('2026-03-01T02:30:00', false, 'Europe/Berlin')], '2026-03-01T00:00:00', '2026-04-01T00:00:00'); + expect(out).toHaveLength(31); + expect(out.at(-1)!.start.slice(0, 16)).toBe('2026-03-31T02:30'); + }); + + it('keeps an all-day series going after a midnight gap (Santiago)', () => { + process.env.TZ = 'America/Santiago'; + const out = expandRecurringEvents([daily('2026-08-20T00:00:00', true)], '2026-09-07T00:00:00', '2026-09-14T00:00:00'); + expect(out).toHaveLength(7); + expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00')); + }); +}); diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts index a224e7c4..c02345f4 100644 --- a/src/lib/calendar-alert-scheduler.ts +++ b/src/lib/calendar-alert-scheduler.ts @@ -83,6 +83,16 @@ export function computeTaskFireTime(task: CalendarEvent, trigger: Alert['trigger if (trigger.offset === undefined) return null; const offsetMs = parseAlertOffset(trigger.offset); if (offsetMs === null) return null; + // RFC 8984 §4.5.2: relativeTo defaults to "start", which for a Task is its + // start; "end" is its due. A task without a start counts from its due + // (#83, webmail #1328). + if (trigger.relativeTo !== 'end' && task.start) { + const start = task.utcStart + ? new Date(task.utcStart) + // The start read like a due: in the task's zone, or a calendar date. + : getTaskDueDate({ due: task.start, timeZone: task.timeZone, showWithoutTime: task.showWithoutTime }); + if (start && !Number.isNaN(start.getTime())) return start.getTime() + offsetMs; + } const due = getTaskDueDate(task); if (!due) return null; return due.getTime() + offsetMs; diff --git a/src/lib/calendar-alerts.ts b/src/lib/calendar-alerts.ts index 2090a8c7..bd93e180 100644 --- a/src/lib/calendar-alerts.ts +++ b/src/lib/calendar-alerts.ts @@ -1,4 +1,4 @@ -import type { Alert, CalendarEvent } from '../api/types'; +import type { Alert, Calendar, CalendarEvent } from '../api/types'; import { formatMessage, type MessageParams } from '../i18n/format'; // A reminder, expressed as "minutes before the event start". 0 means "at the @@ -130,6 +130,57 @@ function reminderMinutes(reminders: Reminder[]): number[] { return [...new Set(reminders.map((r) => r.minutesBefore))].sort((a, b) => b - a); } +/** Whether two reminder lists remind at the same times. */ +export function sameReminders(a: Reminder[], b: Reminder[]): boolean { + const x = reminderMinutes(a); + const y = reminderMinutes(b); + return x.length === y.length && x.every((minutes, i) => minutes === y[i]); +} + +// ─── A calendar's default alerts (#1209) ───────────────── + +/** The alerts `calendar` gives an event with a time, or an all-day one, that uses its defaults. */ +export function calendarDefaultAlerts( + calendar: Pick | null | undefined, + allDay: boolean, +): Record { + return (allDay ? calendar?.defaultAlertsWithoutTime : calendar?.defaultAlertsWithTime) ?? {}; +} + +/** + * A calendar's default alerts as the editor's reminders set them, with the + * ones it can't show (`preserved`) kept as they were. Under ids of their own, + * `${idPrefix}-1` and on: Stalwart 1.0 refuses default alert ids that another + * calendar of the account uses ("Default alert ids must be unique across all + * calendars in the account."), so the prefix names the calendar and the + * list; it also shows them in an event that uses them under the same ids, + * where an event's own reminder-N alerts must not meet them. An emptied list + * is `{}`: Stalwart refuses null for these maps. + */ +/** The id prefix of `calendar`'s default alerts for timed or all-day events, unique in its account. */ +export function defaultAlertIdPrefix(calendar: Pick, allDay: boolean): string { + return `cal-${calendar.originalId || calendar.id}-${allDay ? 'allday' : 'timed'}`; +} + +export function remindersToDefaultAlerts( + reminders: Reminder[], + preserved: Record = {}, + idPrefix = 'default', +): Record { + const alerts: Record = { ...preserved }; + let n = 0; + for (const minutesBefore of reminderMinutes(reminders)) { + let key = `${idPrefix}-${++n}`; + while (key in alerts) key = `${idPrefix}-${++n}`; + alerts[key] = { + '@type': 'Alert', + trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(minutesBefore), relativeTo: 'start' }, + action: 'display', + }; + } + return alerts; +} + /** * What an editor save writes for the reminders: nothing while they are the * ones the event had, so an event that uses its calendar's default alerts diff --git a/src/lib/calendar-editor-keyboard.ts b/src/lib/calendar-editor-keyboard.ts new file mode 100644 index 00000000..0c60776f --- /dev/null +++ b/src/lib/calendar-editor-keyboard.ts @@ -0,0 +1,12 @@ +/** + * How far above its normal bottom the event editor's scroll area has to end + * so the keyboard covers none of it (#81). `obstruction` is how much of the + * screen bottom the keyboard covers (use-keyboard-obstruction), + * `bottomInset` the safe-area inset the editor already keeps clear, and + * `resizedBy` how much the window itself shrank for the keyboard: an + * edge-to-edge Android dialog does not resize at all, a window that does + * must not be shortened twice. + */ +export function editorKeyboardInset(obstruction: number, bottomInset: number, resizedBy: number): number { + return Math.max(0, Math.round(obstruction - bottomInset - Math.max(0, resizedBy))); +} diff --git a/src/lib/calendar-feed-diff.ts b/src/lib/calendar-feed-diff.ts new file mode 100644 index 00000000..6b2cf499 --- /dev/null +++ b/src/lib/calendar-feed-diff.ts @@ -0,0 +1,77 @@ +import type { CalendarEvent } from '../api/types'; +import { normalizeAllDayDuration } from './calendar-utils'; + +/** + * What a subscribed iCal feed decides about an event it still carries, in + * the form the subscription import writes it. Refreshing a subscription + * writes these, and only when the feed changed one of them: alerts, + * participants and anything else stay as the server holds them. Mirrors the + * webmail's lib/calendar-feed-diff. + */ +export function feedFields(event: Partial): Partial { + const allDay = !!event.showWithoutTime; + return { + title: event.title ?? '', + description: event.description ?? '', + start: allDay && event.start ? `${event.start.slice(0, 10)}T00:00:00` : event.start, + // CalendarEvent/parse drops a DTEND equal to DTSTART, and the servers + // store that event with PT0S. + duration: allDay ? normalizeAllDayDuration(event.duration) : (event.duration || 'PT0S'), + timeZone: allDay ? null : (event.timeZone ?? null), + showWithoutTime: allDay, + status: event.status ?? 'confirmed', + locations: event.locations ?? null, + virtualLocations: event.virtualLocations ?? null, + recurrenceRules: event.recurrenceRules ?? null, + // Not excludedRecurrenceRules: 0.16 refuses that property even as null, + // and RFC 5545 has retired EXRULE. + recurrenceOverrides: event.recurrenceOverrides ?? null, + }; +} + +const RULE_DEFAULTS: Record = { interval: 1, rscale: 'gregorian', skip: 'omit', firstDayOfWeek: 'mo' }; + +/** JSON with sorted keys, without `@type` and a rule's default values. */ +function canonical(value: unknown): string { + return JSON.stringify(value, (_key, v) => { + if (!v || typeof v !== 'object' || Array.isArray(v)) return v; + const out: Record = {}; + for (const k of Object.keys(v).sort()) { + if (k === '@type' || RULE_DEFAULTS[k] === (v as Record)[k]) continue; + out[k] = (v as Record)[k]; + } + return out; + }); +} + +// What an override may change and both servers give back as is. 0.16 adds +// `updated` (its own time) and repeats the base duration; 1.0 leaves out +// what equals the series, so a missing value is the series' value. +const OVERRIDE_FIELDS = ['title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'status'] as const; + +function overridesKey(fields: Partial): string { + const out: Record = {}; + for (const [recurrenceId, patch] of Object.entries(fields.recurrenceOverrides ?? {})) { + const p = (patch ?? {}) as Record; + out[recurrenceId] = p.excluded + ? 'excluded' + : OVERRIDE_FIELDS.map((k) => (k in p ? p[k] : fields[k]) ?? null); + } + return canonical(out); +} + +function names(map: Record | null | undefined, key: 'name' | 'uri'): string[] { + return Object.values(map ?? {}).map((entry) => entry?.[key] ?? '').sort(); +} + +/** Whether the feed's copy of an event differs from the stored one in what the feed decides. */ +export function feedChanged(incoming: Partial, stored: Partial): boolean { + const a = feedFields(incoming); + const b = feedFields(stored); + const scalar = ['title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'status'] as const; + if (scalar.some((k) => (a[k] ?? null) !== (b[k] ?? null))) return true; + if (canonical(names(a.locations as never, 'name')) !== canonical(names(b.locations as never, 'name'))) return true; + if (canonical(names(a.virtualLocations as never, 'uri')) !== canonical(names(b.virtualLocations as never, 'uri'))) return true; + if (canonical(a.recurrenceRules) !== canonical(b.recurrenceRules)) return true; + return overridesKey(a) !== overridesKey(b); +} diff --git a/src/lib/calendar-ics-export.ts b/src/lib/calendar-ics-export.ts index 2d29a5d3..b3819313 100644 --- a/src/lib/calendar-ics-export.ts +++ b/src/lib/calendar-ics-export.ts @@ -4,19 +4,38 @@ import { t } from '../stores/locale-store'; // Port of the webmail's lib/calendar-ics-export.ts: serialise one event as // an RFC 5545 VCALENDAR so it can be shared as a .ics file. -const MAX_LINE_OCTETS = 74; +const MAX_LINE_OCTETS = 75; +const utf8 = new TextEncoder(); -function foldLine(line: string): string { - if (line.length <= MAX_LINE_OCTETS) return line; - const chunks: string[] = [line.slice(0, MAX_LINE_OCTETS)]; - let pos = MAX_LINE_OCTETS; - while (pos < line.length) { - chunks.push(' ' + line.slice(pos, pos + MAX_LINE_OCTETS - 1)); - pos += MAX_LINE_OCTETS - 1; +// RFC 5545 §3.1: lines of at most 75 octets, folded between characters. Cut +// by UTF-16 units, a line with umlauts ran past 75 octets and a fold could +// split an emoji, which the file then held as two U+FFFD. +export function foldLine(line: string): string { + const chunks: string[] = []; + let chunk = ''; + let octets = 0; + for (const char of line) { + const size = utf8.encode(char).length; + if (octets + size > MAX_LINE_OCTETS) { + chunks.push(chunk); + chunk = ' '; + octets = 1; + } + chunk += char; + octets += size; } + chunks.push(chunk); return chunks.join('\r\n'); } +// RFC 5545 §3.2: a parameter value holding ":", ";" or "," is quoted, and +// can't hold a DQUOTE or a line break. Escaped like TEXT, "CN=Ops: Alice" +// ended the parameters at the colon and the backslashes stayed in the name. +export function paramValue(value: string): string { + const clean = value.replace(/"/g, "'").replace(/[\r\n]+/g, ' '); + return /[:;,]/.test(clean) ? `"${clean}"` : clean; +} + // RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values. export function escapeText(value: string): string { return value @@ -157,14 +176,14 @@ export function eventToICS(event: CalendarEvent): string { ); const orgEmail = organizer ? participantEmail(organizer) : organizerEmail; if (orgEmail) { - const cn = organizer?.name ? `;CN=${escapeText(organizer.name)}` : ''; + const cn = organizer?.name?.trim() ? `;CN=${paramValue(organizer.name.trim())}` : ''; lines.push(`ORGANIZER${cn}:mailto:${orgEmail}`); } for (const p of Object.values(event.participants)) { if (p === organizer) continue; const email = participantEmail(p); if (!email) continue; - const cn = p.name ? `;CN=${escapeText(p.name)}` : ''; + const cn = p.name?.trim() ? `;CN=${paramValue(p.name.trim())}` : ''; const partstat = p.participationStatus ? `;PARTSTAT=${p.participationStatus.toUpperCase()}` : ';PARTSTAT=NEEDS-ACTION'; diff --git a/src/lib/calendar-invitation.ts b/src/lib/calendar-invitation.ts index b5bdee51..41241a7c 100644 --- a/src/lib/calendar-invitation.ts +++ b/src/lib/calendar-invitation.ts @@ -1,4 +1,6 @@ import type { CalendarEvent, Participant, Email, Attachment, BodyPart, EmailAddress } from '../api/types'; +import { parseDuration, parseLocalDateTime } from './calendar-utils'; +import { getEffectiveTimeZone, isValidTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone'; // ─── Address helpers ───────────────────────────────────── @@ -266,42 +268,170 @@ export function calendarInvitationKey( // ─── Authentication-Results / trust ────────────────────── +interface SpfCheck { + result: string; + identity?: string; + domain?: string; +} + +interface DkimCheck { + result: string; + domain?: string; + selector?: string; +} + export interface AuthenticationResults { - spf?: { result: string; domain?: string }; - dkim?: { result: string; domain?: string; selector?: string }; + /** The headline result; `all` every identity checked (MAIL FROM, HELO). */ + spf?: SpfCheck & { all?: SpfCheck[] }; + /** The first signature; `all` every one the header lists. */ + dkim?: DkimCheck & { all?: DkimCheck[] }; dmarc?: { result: string; domain?: string; policy?: string }; } -/** Parse an Authentication-Results header into SPF / DKIM / DMARC results. */ -export function parseAuthenticationResults(header: string): AuthenticationResults { - const results: AuthenticationResults = {}; - // A header can carry several SPF results (HELO and MAIL FROM); the MAIL FROM - // identity is primary, another identity may only escalate to a failure. - const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g; - const severity: Record = { - fail: 6, softfail: 5, permerror: 4, temperror: 3, neutral: 2, none: 1, pass: 0, - }; - const spf: Array<{ result: string; identity?: string; domain?: string }> = []; - let m: RegExpExecArray | null; - while ((m = spfRegex.exec(header)) !== null) { - spf.push({ result: m[1].toLowerCase(), identity: m[2], domain: m[3] }); +interface ResInfo { + method: string; + result: string; + props: Record; +} + +// The parser below is a port of parseAuthenticationResults in +// lib/email-headers.ts on the reading branch (fx/read), which this branch +// can't import yet: switch to that export (and its +// hasAlignedAuthenticationPass) once both are merged. + +/** + * Split one Authentication-Results header into its `;`-separated parts + * (RFC 8601), dropping comments. A `;` inside a quoted string or a comment + * does not split: both can carry sender-chosen text such as the envelope + * address. + */ +function splitResinfo(header: string): string[] { + const parts: string[] = []; + let current = ''; + let depth = 0; + let quoted = false; + for (let i = 0; i < header.length; i++) { + const c = header[i]; + if (c === '\\' && (quoted || depth > 0)) { + if (depth === 0) current += c + (header[i + 1] ?? ''); + i++; + continue; + } + if (quoted) { + current += c; + if (c === '"') quoted = false; + continue; + } + if (c === '(') { + depth++; + continue; + } + if (depth > 0) { + if (c === ')' && --depth === 0) current += ' '; + continue; + } + if (c === '"') quoted = true; + if (c === ';') { + parts.push(current); + current = ''; + continue; + } + current += c; } + parts.push(current); + return parts.map((part) => part.trim()).filter(Boolean); +} + +const METHOD_RE = /^([a-z0-9][a-z0-9_-]*)(?:\/\d+)?\s*=\s*([a-z]+)(?=\s|$)/i; +const PROP_RE = /\s*([^\s=]+)\s*=\s*("(?:[^"\\]|\\.)*"|\S*)/y; + +/** + * Read one resinfo: the method must open the part, so a `dmarc=pass` that + * appears inside a property value (say an envelope local part in + * `smtp.mailfrom=`) is never taken for a result. + */ +function parseResinfo(part: string): ResInfo | null { + const match = METHOD_RE.exec(part); + if (!match) return null; + const props: Record = {}; + const rest = part.slice(match[0].length); + PROP_RE.lastIndex = 0; + let prop: RegExpExecArray | null; + while ((prop = PROP_RE.exec(rest)) !== null && prop[0].length > 0) { + const key = prop[1].toLowerCase(); + let value = prop[2]; + if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1'); + if (!(key in props)) props[key] = value; + } + return { method: match[1].toLowerCase(), result: match[2].toLowerCase(), props }; +} + +const SPF_SEVERITY: Record = { + fail: 6, softfail: 5, permerror: 4, temperror: 3, neutral: 2, none: 1, pass: 0, +}; + +const DMARC_SEVERITY: Record = { fail: 3, permerror: 2, temperror: 2, none: 1, pass: 0 }; + +/** + * Parse Authentication-Results headers, in message order, into SPF, DKIM + * and DMARC results. The topmost header is the receiving server's own; + * anything below it may have been written by the sender, so DKIM and DMARC + * come from the topmost header only, and the others can only escalate SPF + * to a failure, never supply a pass. A method counts only where it opens a + * `;`-separated part: matched anywhere in the header, a `dkim=pass` written + * into a comment or an envelope address passed for a result. + */ +export function parseAuthenticationResults(headers: string | readonly string[]): AuthenticationResults { + const results: AuthenticationResults = {}; + const list = typeof headers === 'string' ? [headers] : headers; + const perHeader = list.map((header) => splitResinfo(header) + .map(parseResinfo) + .filter((info): info is ResInfo => info !== null)); + const own = perHeader[0] ?? []; + const foreign = perHeader.slice(1).flat(); + + const severity = (r: string) => SPF_SEVERITY[r] ?? -1; + const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror; + const toSpfCheck = (info: ResInfo): SpfCheck => { + const identity = info.props['smtp.mailfrom'] !== undefined + ? 'mailfrom' + : info.props['smtp.helo'] !== undefined ? 'helo' : undefined; + return { result: info.result, identity, domain: identity ? info.props[`smtp.${identity}`] || undefined : undefined }; + }; + const spf: SpfCheck[] = [ + ...own.filter((info) => info.method === 'spf').map(toSpfCheck), + ...foreign.filter((info) => info.method === 'spf').map(toSpfCheck).filter((e) => isFailure(e.result)), + ]; if (spf.length > 0) { + // The MAIL FROM identity is primary; another one (HELO) may only + // escalate the headline to a failure. let primary = spf.find((e) => e.identity === 'mailfrom') ?? spf[0]; for (const cur of spf) { - const s = severity[cur.result] ?? -1; - if (s >= severity.temperror && s > (severity[primary.result] ?? -1)) primary = cur; + if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) primary = cur; } - results.spf = { result: primary.result, domain: primary.domain }; + results.spf = { ...primary, all: spf }; } - const dkim = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/); - if (dkim) results.dkim = { result: dkim[1].toLowerCase(), domain: dkim[2], selector: dkim[3] }; - const dmarc = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/); - if (dmarc) results.dmarc = { result: dmarc[1].toLowerCase(), domain: dmarc[2], policy: dmarc[3] }; + + // A message can carry several signatures (the sender's and a mailing + // list's or a sending service's): alignment looks at each. + const dkim: DkimCheck[] = own + .filter((info) => info.method === 'dkim') + .map((info) => ({ result: info.result, domain: info.props['header.d'], selector: info.props['header.s'] })); + if (dkim.length > 0) results.dkim = { ...dkim[0], all: dkim }; + + // One DMARC verdict per message; should the header carry several, the + // most severe stands. + const dmarc = own + .filter((info) => info.method === 'dmarc') + .reduce( + (worst, info) => (!worst || (DMARC_SEVERITY[info.result] ?? -1) > (DMARC_SEVERITY[worst.result] ?? -1) ? info : worst), + undefined, + ); + if (dmarc) results.dmarc = { result: dmarc.result, domain: dmarc.props['header.from'], policy: dmarc.props['policy.dmarc'] }; return results; } -/** Authentication results of an email, derived from its raw headers. */ +/** Authentication results of an email, from its Authentication-Results headers in message order. */ export function getEmailAuthenticationResults( email?: Pick | null, ): AuthenticationResults | null { @@ -310,22 +440,46 @@ export function getEmailAuthenticationResults( .filter((h) => h.name.toLowerCase() === 'authentication-results') .map((h) => h.value); if (values.length === 0) return null; - // Merge: the first (outermost, added by our own server) header wins per - // mechanism; later ones only fill gaps. - const merged: AuthenticationResults = {}; - for (const value of values) { - const parsed = parseAuthenticationResults(value); - if (!merged.spf && parsed.spf) merged.spf = parsed.spf; - if (!merged.dkim && parsed.dkim) merged.dkim = parsed.dkim; - if (!merged.dmarc && parsed.dmarc) merged.dmarc = parsed.dmarc; - } - return merged; + return parseAuthenticationResults(values); } -function hasVerifiedAuthentication(auth?: AuthenticationResults | null): boolean { - return Boolean( - auth?.dmarc?.result === 'pass' || auth?.dkim?.result === 'pass' || auth?.spf?.result === 'pass', - ); +function domainOf(address: string | undefined): string | undefined { + if (!address) return undefined; + const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, ''); + return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined; +} + +/** + * Whether `candidate` is the From domain or a parent or child of it: the + * relaxed alignment DMARC applies, without a public-suffix list. + */ +function alignedWith(fromDomain: string, candidate: string | undefined): boolean { + const other = domainOf(candidate); + if (!other) return false; + return other === fromDomain || other.endsWith(`.${fromDomain}`) || fromDomain.endsWith(`.${other}`); +} + +/** + * Whether the receiving server's checks tie the message to the domain of its + * From address: a DMARC pass, or an SPF (MAIL FROM, never HELO) or DKIM pass + * for a domain aligned with it. A bare pass says nothing about the visible + * sender: anyone passes SPF for an envelope domain of their own, or signs + * with their own key, and still writes the organizer's address in From; with + * no DMARC policy on that domain the verdict is `dmarc=none` (#1374). Mirrors + * the webmail's hasAlignedAuthenticationPass. + */ +export function hasAlignedAuthenticationPass( + auth: AuthenticationResults | null | undefined, + fromEmail: string | null | undefined, +): boolean { + if (!auth || !fromEmail) return false; + const fromDomain = domainOf(fromEmail); + if (!fromDomain) return false; + if (auth.dmarc?.result === 'pass') return true; + const spf = auth.spf?.all ?? (auth.spf ? [auth.spf] : []); + if (spf.some((e) => e.identity !== 'helo' && e.result === 'pass' && alignedWith(fromDomain, e.domain))) return true; + const dkim = auth.dkim?.all ?? (auth.dkim ? [auth.dkim] : []); + return dkim.some((e) => e.result === 'pass' && alignedWith(fromDomain, e.domain)); } function hasAuthenticationFailure(auth?: AuthenticationResults | null): boolean { @@ -361,19 +515,28 @@ export interface InvitationTrustAssessment { * email plus whether the sender matches the event's organizer. A spoofed * invitation from an unauthenticated sender must not look like a real one. * Port of the webmail's getInvitationTrustAssessment. + * + * `ownAddresses` are the reader's own addresses. Mail from another user on + * the reader's domains that carries no Authentication-Results header at all + * was handed in by a signed-in user rather than received from outside: + * Stalwart adds the header to everything that arrives on port 25 (#1374). */ export function getInvitationTrustAssessment( event: Partial, email?: Pick | null, method: InvitationMethod = inferInvitationMethod(event), + ownAddresses: readonly string[] = [], ): InvitationTrustAssessment { const organizerEmail = getOrganizerEmail(event); const senderEmail = getPrimaryAddressEmail(email?.from) || getPrimaryAddressEmail(email?.replyTo); const auth = getEmailAuthenticationResults(email); - const verified = hasVerifiedAuthentication(auth); + const verified = hasAlignedAuthenticationPass(auth, getPrimaryAddressEmail(email?.from)); const failed = hasAuthenticationFailure(auth); const senderMismatch = Boolean(senderEmail && organizerEmail && senderEmail !== organizerEmail); - const expectsAuthenticatedTransport = method !== 'unknown'; + const senderDomain = domainOf(senderEmail ?? undefined); + const internal = Boolean(email && !auth && senderDomain + && ownAddresses.some((address) => domainOf(address) === senderDomain)); + const expectsAuthenticatedTransport = method !== 'unknown' && !internal; if (senderMismatch && (failed || !verified)) { return { level: 'warning', reason: 'sender_mismatch_unverified', senderEmail, organizerEmail }; @@ -389,3 +552,75 @@ export function getInvitationTrustAssessment( } return { level: 'trusted', reason: null, senderEmail, organizerEmail }; } + +// ─── When ──────────────────────────────────────────────── + +/** What the invitation banner shows as an event's time. */ +export interface InvitationDisplayRange { + /** Display dates (see toDisplayDate): their local fields are what to show. */ + start: Date; + end: Date | null; + /** + * The real start instant, for an event at a fixed moment (a UTC time or a + * zoned wall clock) shown in the user's zone: worth a zone label. Unset + * for all-day and floating events, which read the same everywhere. + */ + instant?: Date; +} + +const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i; + +/** + * The banner's start and end of an invitation. CalendarEvent/parse gives + * `start` as a wall clock in `timeZone` and no `utcStart`, so the start has + * to be resolved in that zone and shown in the user's: read as a device + * time, it showed the organizer's clock without saying so (webmail #1252). + * An all-day event gets its last day (the end is exclusive) or none for a + * single day; a floating time stays as written. + */ +export function getInvitationDisplayRange( + event: Partial>, +): InvitationDisplayRange | null { + if (!event.start) return null; + const durationMs = parseDuration(event.duration); + if (event.showWithoutTime) { + const start = parseLocalDateTime(event.start.slice(0, 10)); + if (isNaN(start.getTime())) return null; + const days = Math.round(durationMs / 86_400_000); + if (days <= 1) return { start, end: null }; + const last = new Date(start); + last.setDate(last.getDate() + days - 1); + return { start, end: last }; + } + + let instant: Date | null = null; + if (event.utcStart) instant = new Date(event.utcStart); + else if (HAS_UTC_OFFSET.test(event.start)) instant = new Date(event.start); + else if (event.timeZone && isValidTimeZone(event.timeZone)) instant = localDateTimeToInstant(event.start, event.timeZone); + if (instant && !isNaN(instant.getTime())) { + let end: Date | null = event.utcEnd ? new Date(event.utcEnd) : null; + if ((!end || isNaN(end.getTime())) && durationMs > 0) end = new Date(instant.getTime() + durationMs); + return { + start: toDisplayDate(instant), + end: end && !isNaN(end.getTime()) && end.getTime() > instant.getTime() ? toDisplayDate(end) : null, + instant, + }; + } + + // Floating: the same wall clock everywhere. + const start = parseLocalDateTime(event.start); + if (isNaN(start.getTime())) return null; + return { start, end: durationMs > 0 ? new Date(start.getTime() + durationMs) : null }; +} + +/** The short name of the user's calendar zone at `instant` ("CET", "GMT+1"), or null. */ +export function invitationZoneLabel(instant: Date, locale?: string): string | null { + try { + const part = new Intl.DateTimeFormat(locale, { timeZone: getEffectiveTimeZone(), timeZoneName: 'short' }) + .formatToParts(instant) + .find((p) => p.type === 'timeZoneName'); + return part?.value ?? null; + } catch { + return null; + } +} diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index 666f510a..063a2e2d 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -355,9 +355,12 @@ export function buildParticipantMap( // stored as an inert JSPROP, so it is intentionally not sent. const organizerKey = organizer.email.trim().toLowerCase(); const organizerStatus = known.get(organizerKey)?.status; + // A blank name is left out: written, it reaches the attendees' copies as + // an empty CN. + const organizerName = organizer.name?.trim(); participants[idFor(organizerKey)] = { '@type': 'Participant', - name: organizer.name, + ...(organizerName ? { name: organizerName } : {}), email: organizer.email, calendarAddress: `mailto:${organizer.email}`, // owner only, NOT attendee: with roles.attendee set, Stalwart's server-side @@ -380,9 +383,10 @@ export function buildParticipantMap( if (!key || seen.has(key)) return; seen.add(key); + const name = a.name?.trim(); participants[idFor(key)] = { '@type': 'Participant', - name: a.name, + ...(name ? { name } : {}), email, calendarAddress: `mailto:${email}`, roles: { attendee: true }, diff --git a/src/lib/calendar-search.ts b/src/lib/calendar-search.ts new file mode 100644 index 00000000..5876b678 --- /dev/null +++ b/src/lib/calendar-search.ts @@ -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 | 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(); + 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 }; +} diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index 1e85dfed..9cbb7cbc 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -113,7 +113,11 @@ export function addDaysToLocalDateTime(value: string, days: number): string { /** End of an event as a display date - see getEventStartDate. */ export function getEventEndDate(event: CalendarEvent): Date { - if (!event.showWithoutTime && event.utcEnd) { + // A timed event without a duration lasts zero seconds (RFC 8984's default + // PT0S). Stalwart stores a DTEND equal to DTSTART as no duration and gives + // the event a utcEnd of 23:59:59 on its start day, so it filled the rest + // of the day in the grid (#837). + if (!event.showWithoutTime && event.utcEnd && event.duration) { const utc = parseISO(event.utcEnd); if (!isNaN(utc.getTime())) return toDisplayDate(utc); } diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index cdef6eba..fae990ec 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -524,6 +524,16 @@ function generateCandidatesForPeriod( candidates = expandWeekly(periodStart, rule, eventStart); break; case 'daily': + // The start's wall-clock time on this day. The period anchor is + // advanced day by day and keeps whatever time a DST gap pushed it to + // (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using + // it made every later day fail the implicit byHour and the series + // stopped at the gap. Mirrors the webmail (34fb9d247). + candidates = [new Date( + periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(), + eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(), + )]; + break; case 'hourly': case 'minutely': case 'secondly': @@ -532,7 +542,10 @@ function generateCandidatesForPeriod( break; } - candidates = candidates.filter((d) => matchesByX(d, rule)); + // A daily candidate is judged by the time it was built for: on the day of + // a DST gap that time does not exist and the occurrence moves past the gap + // (RFC 5545 3.3.5), which is still this occurrence. + candidates = candidates.filter((d) => matchesByX(d, rule, freq === 'daily' ? eventStart : undefined)); candidates.sort((a, b) => a.getTime() - b.getTime()); return candidates; } @@ -660,7 +673,7 @@ function expandWeekly( return dates.sort((a, b) => a.getTime() - b.getTime()); } -function matchesByX(date: Date, rule: RecurrenceRule): boolean { +function matchesByX(date: Date, rule: RecurrenceRule, timeOf: Date = date): boolean { if (rule.byMonth?.length) { const month = String(date.getMonth() + 1); if (!rule.byMonth.some((m) => m.replace('L', '') === month)) return false; @@ -706,9 +719,9 @@ function matchesByX(date: Date, rule: RecurrenceRule): boolean { }) ) return false; } - if (rule.byHour?.length && !rule.byHour.includes(date.getHours())) return false; - if (rule.byMinute?.length && !rule.byMinute.includes(date.getMinutes())) return false; - if (rule.bySecond?.length && !rule.bySecond.includes(date.getSeconds())) return false; + if (rule.byHour?.length && !rule.byHour.includes(timeOf.getHours())) return false; + if (rule.byMinute?.length && !rule.byMinute.includes(timeOf.getMinutes())) return false; + if (rule.bySecond?.length && !rule.bySecond.includes(timeOf.getSeconds())) return false; return true; } diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 6c8bedf7..6ed8d41b 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -23,6 +23,7 @@ import { ListChecks, Menu, Rows2, + Search, Square, } from '../components/icons'; import { @@ -60,6 +61,7 @@ import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet'; import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet'; +import { CalendarSearchSheet } from '../components/calendar/CalendarSearchSheet'; import { computeScrollWindow, fixedScrollWindowState, @@ -93,7 +95,7 @@ import { buildReplyTo } from '../lib/calendar-invitation'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit'; import { participantLimitMessage } from '../lib/calendar-participants'; -import { maxParticipantsPerEvent } from '../api/calendar'; +import { maxParticipantsPerEvent, type CalendarUpdates } from '../api/calendar'; import { useContactNameResolver } from '../lib/contact-name-resolver'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; import { moveCalendar } from '../lib/calendar-sort'; @@ -225,6 +227,7 @@ export default function CalendarScreen() { }, [scrollWindow]); const [detailEvent, setDetailEvent] = React.useState(null); + const [searchVisible, setSearchVisible] = React.useState(false); const [modalEvent, setModalEvent] = React.useState(null); const [modalDate, setModalDate] = React.useState(undefined); const [modalVisible, setModalVisible] = React.useState(false); @@ -880,7 +883,10 @@ export default function CalendarScreen() { } else { // Only what the user changed: an untouched colour another client // wrote may be one Stalwart 1.0 refuses. - const changes = changedCalendarFields(calendarEditTarget.calendar, values); + const changes: CalendarUpdates = changedCalendarFields(calendarEditTarget.calendar, values); + // The default reminders come only when the user changed them. + if (values.defaultAlertsWithTime) changes.defaultAlertsWithTime = values.defaultAlertsWithTime; + if (values.defaultAlertsWithoutTime) changes.defaultAlertsWithoutTime = values.defaultAlertsWithoutTime; if (Object.keys(changes).length > 0) await updateCalendar(calendarEditTarget.calendar.id, changes); } }, @@ -979,6 +985,18 @@ export default function CalendarScreen() {
+ { + setMonthPanelOpen(false); + setSearchVisible(true); + }} + hitSlop={4} + accessibilityRole="button" + accessibilityLabel={t('calendar.search.title', 'Search events')} + > + + {/* Without free scrolling the views don't swipe: keep arrows. */} {!freeScroll && ( <> @@ -1280,6 +1298,15 @@ export default function CalendarScreen() { onClose={() => setCalendarEditTarget(null)} /> + setSearchVisible(false)} + /> + ({ + getCalendars: vi.fn(), + queryEvents: vi.fn(async () => []), + getEvents: vi.fn(async () => []), + getEventIdentities: vi.fn(async () => []), + scanCalendarObjects: vi.fn(async () => []), + isCalendarAccessDenied: vi.fn(() => false), + noteCalendarAccessError: vi.fn(() => false), + resetCalendarAccessDenied: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(async () => undefined), + deleteEvents: vi.fn(async () => undefined), + batchCreateEvents: vi.fn(async (events: unknown[]) => ({ created: events.length, refused: [] })), + rsvpEvent: vi.fn(async () => undefined), + createCalendar: vi.fn(), + updateCalendar: vi.fn(async () => undefined), + deleteCalendar: vi.fn(async () => undefined), + setDefaultCalendar: vi.fn(async () => undefined), + supportsSyntheticCalendarIds: vi.fn(async () => false), + queryExpandedEvents: vi.fn(async () => []), + searchEventIds: vi.fn(async () => []), + hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), + resetSyntheticIdSupport: vi.fn(), + resetTimeZoneChecks: vi.fn(), +})); + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { accountId: 'acc-1', isConnected: true }, +})); + +vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() })); + +import * as calendarApi from '../../api/calendar'; +import type { CalendarEvent } from '../../api/types'; +import { CalendarAccountChangedError, searchCalendarEvents, useCalendarStore } from '../calendar-store'; + +const mockUpdate = vi.mocked(calendarApi.updateEvent); +const mockDelete = vi.mocked(calendarApi.deleteEvents); +const mockCreate = vi.mocked(calendarApi.createEvent); +const mockGetEvents = vi.mocked(calendarApi.getEvents); +const mockQueryEvents = vi.mocked(calendarApi.queryEvents); +const mockQueryExpanded = vi.mocked(calendarApi.queryExpandedEvents); +const mockBatchCreate = vi.mocked(calendarApi.batchCreateEvents); +const mockUpdateCalendar = vi.mocked(calendarApi.updateCalendar); +const mockCreateCalendar = vi.mocked(calendarApi.createCalendar); + +const RID = '2026-03-02T09:00:00'; +const participants = { + p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, +}; + +const event = (id: string, title: string): CalendarEvent => ({ + id, uid: `${id}@example.com`, title, start: '2026-03-03T10:00:00', duration: 'PT1H', calendarIds: { b: true }, +} as unknown as CalendarEvent); + +// The next account: its calendar "b" holds an event "c" too. +const THEIRS = event('c', 'Their event'); + +/** What an account switch does to the store while a request is under way. */ +function switchAccount(): void { + useCalendarStore.getState().reset(); + useCalendarStore.setState({ + calendars: [{ id: 'b', name: 'Their calendar', sortOrder: 0 }] as never, + events: [THEIRS], + tasks: [{ ...THEIRS, '@type': 'Task', progress: 'needs-action' } as CalendarEvent], + loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-04-01T00:00:00Z' }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + useCalendarStore.getState().reset(); + useCalendarStore.setState({ + calendars: [{ id: 'b', name: 'Mine', sortOrder: 0 }, { id: 'e', name: 'Other', sortOrder: 1 }] as never, + events: [event('c', 'My event')], + tasks: [{ ...event('c', 'My task'), '@type': 'Task', progress: 'needs-action' } as CalendarEvent], + loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-04-01T00:00:00Z' }, + }); +}); + +describe('a write the account switch overtook', () => { + it('names the account it started in and leaves the next account\'s event alone (updateEvent)', async () => { + mockUpdate.mockImplementationOnce(async () => switchAccount()); + + await useCalendarStore.getState().updateEvent('c', { title: 'Renamed' }); + + expect(mockUpdate).toHaveBeenCalledWith('c', { title: 'Renamed' }, undefined, 'acc-1'); + expect(useCalendarStore.getState().events).toEqual([THEIRS]); + }); + + it('leaves the next account\'s event of the same id in the store (deleteEvent)', async () => { + mockDelete.mockImplementationOnce(async () => switchAccount()); + + await useCalendarStore.getState().deleteEvent('c'); + + expect(mockDelete).toHaveBeenCalledWith(['c'], undefined, 'acc-1'); + expect(useCalendarStore.getState().events).toEqual([THEIRS]); + }); + + it('neither re-reads nor shows the event it made (createEvent)', async () => { + mockCreate.mockImplementationOnce(async () => { + switchAccount(); + return event('n', 'New'); + }); + + await useCalendarStore.getState().createEvent({ title: 'New' }, 'b'); + + expect(mockCreate).toHaveBeenCalledWith({ title: 'New' }, 'b', undefined, 'acc-1'); + expect(mockGetEvents).not.toHaveBeenCalled(); + expect(useCalendarStore.getState().events).toEqual([THEIRS]); + }); + + it('leaves the answer off the next account\'s event (rsvpEvent)', async () => { + useCalendarStore.setState({ events: [{ ...event('c', 'Invite'), participants } as unknown as CalendarEvent] }); + const theirs = { ...THEIRS, participants }; + vi.mocked(calendarApi.rsvpEvent).mockImplementationOnce(async () => { + switchAccount(); + useCalendarStore.setState({ events: [theirs as unknown as CalendarEvent] }); + }); + + await useCalendarStore.getState().rsvpEvent('c', 'p1', 'declined'); + + expect(calendarApi.rsvpEvent).toHaveBeenCalledWith('c', 'p1', 'declined', undefined, 'acc-1'); + expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('accepted'); + }); + + it('sends no second step of a first change to one server occurrence', async () => { + useCalendarStore.setState({ + events: [{ + id: 's1', baseEventId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H', + title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null, + calendarIds: { b: true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1, + } as unknown as CalendarEvent], + }); + mockUpdate.mockImplementationOnce(async () => switchAccount()); + + await expect(useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial, { + sendSchedulingMessages: true, + })).rejects.toBeInstanceOf(CalendarAccountChangedError); + + // The quiet override only: the change itself would go to the next account. + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate.mock.calls[0][2]).toBe(false); + expect(mockQueryExpanded).not.toHaveBeenCalled(); + }); + + it('sends no second step of a first change to one occurrence expanded on the device', async () => { + useCalendarStore.setState({ + events: [{ + id: `base:${RID}`, originalId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H', + title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null, + calendarIds: { b: true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1, + } as unknown as CalendarEvent], + }); + mockUpdate.mockImplementationOnce(async () => switchAccount()); + + await expect(useCalendarStore.getState().updateEvent(`base:${RID}`, { title: 'Standup, room 2', participants } as Partial, { + sendSchedulingMessages: true, + })).rejects.toBeInstanceOf(CalendarAccountChangedError); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate.mock.calls[0][2]).toBe(false); + }); + + it('imports no further batch into the next account\'s calendar of the same id', async () => { + mockBatchCreate.mockImplementationOnce(async (events: unknown[]) => { + switchAccount(); + return { created: events.length, refused: [] }; + }); + const events = Array.from({ length: 60 }, (_, i) => ({ uid: `u${i}`, title: `E${i}`, start: '2026-03-03T10:00:00' })); + + await expect(useCalendarStore.getState().importEvents(events, 'b')).rejects.toBeInstanceOf(CalendarAccountChangedError); + + expect(mockQueryEvents).toHaveBeenCalledWith([], '', '', 'acc-1'); + expect(mockBatchCreate).toHaveBeenCalledTimes(1); + expect(mockBatchCreate.mock.calls[0][2]).toBe('acc-1'); + }); + + it('links no further UID once the account changed (importEvents)', async () => { + vi.mocked(calendarApi.getEventIdentities).mockResolvedValueOnce([ + { id: 'x1', uid: 'u1', calendarIds: { e: true } }, + { id: 'x2', uid: 'u2', calendarIds: { e: true } }, + ] as never); + mockUpdate.mockImplementationOnce(async () => switchAccount()); + + await expect(useCalendarStore.getState().importEvents([{ uid: 'u1' }, { uid: 'u2' }], 'b')) + .rejects.toBeInstanceOf(CalendarAccountChangedError); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockBatchCreate).not.toHaveBeenCalled(); + }); + + it('renumbers none of the next account\'s calendars (reorderCalendars)', async () => { + useCalendarStore.setState({ calendars: [{ id: 'b', name: 'Mine', sortOrder: 5 }, { id: 'e', name: 'Other', sortOrder: 6 }] as never }); + mockUpdateCalendar.mockImplementationOnce(async () => switchAccount()); + + await expect(useCalendarStore.getState().reorderCalendars(['e', 'b'])).rejects.toBeInstanceOf(CalendarAccountChangedError); + + expect(mockUpdateCalendar).toHaveBeenCalledTimes(1); + expect(mockUpdateCalendar.mock.calls[0][2]).toBe('acc-1'); + expect(useCalendarStore.getState().calendars).toEqual([{ id: 'b', name: 'Their calendar', sortOrder: 0 }]); + }); + + it('adds the calendar it made to no other account\'s list (createCalendar)', async () => { + mockCreateCalendar.mockImplementationOnce(async () => { + switchAccount(); + return { id: 'n', name: 'New' } as never; + }); + + await useCalendarStore.getState().createCalendar('New'); + + expect(mockCreateCalendar).toHaveBeenCalledWith('New', undefined, undefined, 'acc-1'); + expect(useCalendarStore.getState().calendars.map((c) => c.id)).toEqual(['b']); + }); + + it('removes none of the next account\'s calendar or events (removeCalendar)', async () => { + vi.mocked(calendarApi.deleteCalendar).mockImplementationOnce(async () => switchAccount()); + + await useCalendarStore.getState().removeCalendar('b'); + + expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('b', 'acc-1'); + expect(useCalendarStore.getState().calendars.map((c) => c.id)).toEqual(['b']); + expect(useCalendarStore.getState().events).toEqual([THEIRS]); + }); + + it('changes none of the next account\'s tasks', async () => { + mockUpdate.mockImplementationOnce(async () => switchAccount()); + await useCalendarStore.getState().updateTask('c', { title: 'Renamed' }); + expect(useCalendarStore.getState().tasks[0].title).toBe('Their event'); + + mockDelete.mockImplementationOnce(async () => switchAccount()); + await useCalendarStore.getState().deleteTask('c'); + expect(useCalendarStore.getState().tasks).toHaveLength(1); + + // A refused tick is put back only on the task it was made on. + useCalendarStore.setState({ tasks: [{ ...event('c', 'Mine'), '@type': 'Task', progress: 'needs-action' } as CalendarEvent] }); + mockUpdate.mockImplementationOnce(async () => { + switchAccount(); + useCalendarStore.setState({ tasks: [{ ...THEIRS, '@type': 'Task', progress: 'completed' } as CalendarEvent] }); + throw new Error('Network request failed'); + }); + await expect(useCalendarStore.getState().toggleTaskComplete('c')).rejects.toThrow('Network request failed'); + 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, + }))); + + 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(); + }); +}); diff --git a/src/stores/__tests__/calendar-store-occurrence-answers.test.ts b/src/stores/__tests__/calendar-store-occurrence-answers.test.ts index 529fc654..c661b356 100644 --- a/src/stores/__tests__/calendar-store-occurrence-answers.test.ts +++ b/src/stores/__tests__/calendar-store-occurrence-answers.test.ts @@ -111,7 +111,7 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St ['s9', { title: 'Standup, room 2', participants }, true], ]); // Looked up around the occurrence, expanded. - expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], undefined, { expanded: true }); + expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], 'acc-1', { expanded: true }); }); it('sends the change to the same id when the override keeps it (recurrence-keyed ids, live)', async () => { @@ -134,7 +134,7 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St } as Partial, { sendSchedulingMessages: true }); expect(mockUpdate).toHaveBeenCalledTimes(1); - expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, undefined]); + expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, 'acc-1']); }); it('sends the change once to an occurrence that has its override already', async () => { @@ -248,7 +248,7 @@ describe('an occurrence\'s alerts on a series that uses the calendar\'s default await useCalendarStore.getState().updateEvent(`base:${RID}`, edit); - expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, undefined]); + expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, 'acc-1']); expect(mockUpdate.mock.calls[1][0]).toBe('base'); expect(mockUpdate.mock.calls[1][1]).toHaveProperty('recurrenceOverrides'); }); diff --git a/src/stores/__tests__/calendar-store-sort-order.test.ts b/src/stores/__tests__/calendar-store-sort-order.test.ts index 0d2d6cde..719b982e 100644 --- a/src/stores/__tests__/calendar-store-sort-order.test.ts +++ b/src/stores/__tests__/calendar-store-sort-order.test.ts @@ -60,8 +60,8 @@ describe('calendar order', () => { await done; expect(mockUpdateCalendar.mock.calls).toEqual([ - ['b', { sortOrder: 1 }, undefined], - ['a', { sortOrder: 2 }, undefined], + ['b', { sortOrder: 1 }, 'acc-1'], + ['a', { sortOrder: 2 }, 'acc-1'], ]); }); diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index be8465dc..ab33af99 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -38,12 +38,16 @@ vi.mock('../../api/jmap-client', () => ({ })); import * as calendarApi from '../../api/calendar'; +import { jmapClient } from '../../api/jmap-client'; import { JMAPMethodError } from '../../api/jmap-result'; import { useCalendarStore, selectVisibleCalendars, selectVisibleEvents, ImportRefusedError, + CalendarAccountChangedError, + loadEventsInRange, + prepareImportedEvent, } from '../calendar-store'; const mockGetCalendars = calendarApi.getCalendars as ReturnType; @@ -213,6 +217,39 @@ describe('calendar-store', () => { expect(state.loadedRange).toBeNull(); expect(state.error).toBeNull(); }); + + // The reminders and the widget read ranges with loadEventsInRange + // directly, without a store generation of their own. + it('hands no events read before the reset to a range read outside the store', async () => { + // The reset comes while the account's events are being read. (The + // test above leaves its answer unread: its load stops before asking.) + mockQueryEvents.mockReset(); + mockQueryEvents.mockImplementationOnce(async () => { + useCalendarStore.getState().reset(); + return ['e-old']; + }); + mockGetEvents.mockResolvedValue([{ id: 'e-old', title: 'Old account event', start: '2026-03-20T15:00:00', calendarIds: { 'cal-1': true } }]); + + await expect(loadEventsInRange([{ id: 'cal-1' }] as never, ['cal-1'], '2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z')) + .rejects.toBeInstanceOf(CalendarAccountChangedError); + }); + + it('reads no further account after the reset', async () => { + mockQueryEvents.mockReset(); + mockQueryEvents.mockImplementationOnce(async () => { + useCalendarStore.getState().reset(); + return []; + }); + const calendars = [ + { id: 'cal-1' }, + { id: 'acc-2:cal-9', originalId: 'cal-9', accountId: 'acc-2' }, + ]; + const read = loadEventsInRange(calendars as never, ['cal-1', 'acc-2:cal-9'], '2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z'); + + await expect(read).rejects.toBeInstanceOf(CalendarAccountChangedError); + // The shared account would be read under the next login. + expect(mockQueryEvents).toHaveBeenCalledTimes(1); + }); }); describe('fetchTasks', () => { @@ -382,6 +419,58 @@ describe('calendar-store', () => { expect(mockGetCalendars).toHaveBeenCalled(); expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); }); + + it('marks an account without calendars as loaded', async () => { + mockGetCalendars.mockResolvedValue([]); + + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + + expect(useCalendarStore.getState().loadedRange).toEqual({ after: '2026-03-01', before: '2026-03-31' }); + }); + + it('leaves the range unloaded when the calendars fail to load, so the next call tries again', async () => { + mockGetCalendars.mockRejectedValueOnce(new Error('Network request failed')); + + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + expect(useCalendarStore.getState().loadedRange).toBeNull(); + + mockGetCalendars.mockResolvedValueOnce([{ id: 'cal-1', name: 'Personal' }]); + mockQueryEvents.mockResolvedValue([]); + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); + }); + + it('leaves the range unloaded without a session (an account loading)', async () => { + const client = jmapClient as { isConnected: boolean }; + client.isConnected = false; + try { + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + await useCalendarStore.getState().extendRange('2026-03-01', '2026-03-31'); + } finally { + client.isConnected = true; + } + expect(useCalendarStore.getState().loadedRange).toBeNull(); + expect(mockGetCalendars).not.toHaveBeenCalled(); + }); + + it.each(['ensureRange', 'extendRange'] as const)('marks nothing loaded for the next account when a reset overtakes the calendar load (%s)', async (action) => { + let resolveCalendars: (v: unknown) => void = () => {}; + mockGetCalendars.mockImplementationOnce( + () => new Promise((resolve) => { resolveCalendars = resolve; }), + ); + if (action === 'extendRange') { + // A cold start: the window was cached, the calendars not yet loaded. + useCalendarStore.setState({ loadedRange: { after: '2026-02-01', before: '2026-03-01' } }); + } + const load = useCalendarStore.getState()[action]('2026-03-01', '2026-03-31'); + + useCalendarStore.getState().reset(); + resolveCalendars([]); + await load; + + expect(useCalendarStore.getState().loadedRange).toBeNull(); + expect(useCalendarStore.getState().calendars).toEqual([]); + }); }); describe('handleStateChange', () => { @@ -507,7 +596,7 @@ describe('calendar-store', () => { 'cal-1', ); - expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], undefined); + expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], 'acc-1'); expect(result.utcStart).toBe('2026-03-02T08:00:00Z'); const events = useCalendarStore.getState().events; expect(events).toHaveLength(3); @@ -541,7 +630,7 @@ describe('calendar-store', () => { // The third arg is the iMIP scheduling flag — undefined here since the // event has no participants to notify. The fourth is the owning // account — undefined for primary-account events. - expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, undefined); + expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, 'acc-1'); }); }); @@ -558,7 +647,7 @@ describe('calendar-store', () => { await useCalendarStore.getState().setDefaultCalendar('cal-2'); - expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', undefined); + expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', 'acc-1'); const calendars = useCalendarStore.getState().calendars; expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false); expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true); @@ -602,7 +691,7 @@ describe('calendar-store', () => { ); await useCalendarStore.getState().deleteEvent(master!.id); - expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, undefined); + expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, 'acc-1'); // The visible range was reloaded (empty here). expect(mockQueryEvents).toHaveBeenCalled(); }); @@ -632,10 +721,10 @@ describe('calendar-store', () => { // No overrides yet: a pointer into the missing map would fail, so the // whole map is sent. expect(mockUpdateEvent).toHaveBeenNthCalledWith( - 1, 'ev1', { recurrenceOverrides: { '2026-03-02T09:00:00': { excluded: true } } }, undefined, undefined, + 1, 'ev1', { recurrenceOverrides: { '2026-03-02T09:00:00': { excluded: true } } }, undefined, 'acc-1', ); expect(mockUpdateEvent).toHaveBeenNthCalledWith( - 2, 'ev1', { 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true } }, undefined, undefined, + 2, 'ev1', { 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true } }, undefined, 'acc-1', ); }); }); @@ -669,7 +758,7 @@ describe('calendar-store', () => { ); // The UID in cal-1 is linked into cal-2; the one already in cal-2 is skipped. - expect(mockUpdateEvent).toHaveBeenCalledWith('e1', { calendarIds: { 'cal-1': true, 'cal-2': true } }, undefined, undefined); + expect(mockUpdateEvent).toHaveBeenCalledWith('e1', { calendarIds: { 'cal-1': true, 'cal-2': true } }, undefined, 'acc-1'); expect(count).toBe(2); expect(refused).toEqual([]); const created = batch.mock.calls[0][0][0]; @@ -854,7 +943,7 @@ describe('calendar-store', () => { start: '2026-03-02T10:00:00', locations: { l: { name: 'Room 1' } }, sequence: 2, - }, undefined, undefined); + }, undefined, 'acc-1'); expect(mockQueryExpanded).toHaveBeenCalled(); }); @@ -879,14 +968,14 @@ describe('calendar-store', () => { sequence: 2, }, }, - }, undefined, undefined); + }, undefined, 'acc-1'); // Remembered: a delete goes straight to the exclusion. await useCalendarStore.getState().deleteEvent('s2'); expect(mockDeleteEvents).not.toHaveBeenCalled(); expect(mockUpdateEvent).toHaveBeenLastCalledWith('base', { recurrenceOverrides: { '2026-03-09T09:00:00': { excluded: true } }, - }, undefined, undefined); + }, undefined, 'acc-1'); } finally { useCalendarStore.getState().reset(); } @@ -901,7 +990,7 @@ describe('calendar-store', () => { await useCalendarStore.getState().deleteEvent('s1'); - expect(mockDeleteEvents).toHaveBeenCalledWith(['s1'], undefined, undefined); + expect(mockDeleteEvents).toHaveBeenCalledWith(['s1'], undefined, 'acc-1'); expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['s2']); }); @@ -914,7 +1003,7 @@ describe('calendar-store', () => { await useCalendarStore.getState().deleteEvent('s5'); - expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, undefined); + expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, 'acc-1'); expect(useCalendarStore.getState().events).toEqual([]); }); @@ -957,7 +1046,7 @@ describe('calendar-store', () => { await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted'); - expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, undefined); + expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, 'acc-1'); }); it('answers just one server occurrence through its synthetic id', async () => { @@ -983,7 +1072,7 @@ describe('calendar-store', () => { expect(mockUpdateEvent).toHaveBeenCalledWith('s1', { sequence: 4, participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } }, - }, true, undefined); + }, true, 'acc-1'); expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined'); }); }); @@ -1014,7 +1103,7 @@ describe('calendar-store', () => { participants: { me: { calendarAddress: 'mailto:me@x', participationStatus: 'tentative' } }, }, }, - }, true, undefined); + }, true, 'acc-1'); }); }); @@ -1046,7 +1135,7 @@ describe('calendar-store', () => { // Never the series itself: the change becomes this occurrence's override. expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { 'recurrenceOverrides/a': { start: '2026-03-02T09:00:00', duration: 'PT30M', title: 'New' }, - }, undefined, undefined); + }, undefined, 'acc-1'); // Refetched: siblings now carry the server's state. const events = useCalendarStore.getState().events; expect(events.length).toBeGreaterThan(0); @@ -1065,7 +1154,7 @@ describe('calendar-store', () => { await useCalendarStore.getState().updateEvent('ev1', { calendarIds: { 'acc-2:cal-9': true } }); - expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { calendarIds: { 'cal-9': true } }, undefined, undefined); + expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { calendarIds: { 'cal-9': true } }, undefined, 'acc-1'); }); it('getMasterEvent fetches the master an occurrence points at', async () => { @@ -1108,7 +1197,7 @@ describe('calendar-store', () => { // No organizer on the event: repaired from the invitation's replyTo. expect(mockRsvpEvent).toHaveBeenCalledWith( - 'ev9', 'p1', 'accepted', 'mailto:boss@example.com', undefined, + 'ev9', 'p1', 'accepted', 'mailto:boss@example.com', 'acc-1', ); }); @@ -1142,7 +1231,7 @@ describe('calendar-store', () => { 't1', { progress: 'completed', percentComplete: 100 }, undefined, - undefined, + 'acc-1', ); expect(useCalendarStore.getState().tasks[0].progress).toBe('completed'); }); @@ -1176,7 +1265,7 @@ describe('calendar-store', () => { 't1', { progress: 'needs-action', percentComplete: 0 }, undefined, - undefined, + 'acc-1', ); }); @@ -1328,7 +1417,7 @@ describe('editing one occurrence of a series', () => { participants: { ...participants, me: { ...participants.me, participationStatus: 'tentative' } }, }, }, - }, true, undefined); + }, true, 'acc-1'); }); it('keeps the 0.16.19 fallback payload for a server that rejects synthetic ids', async () => { @@ -1357,3 +1446,13 @@ describe('editing one occurrence of a series', () => { expect(mockUpdateEvent).toHaveBeenCalledTimes(1); }); }); + +describe('prepareImportedEvent', () => { + // #837: CalendarEvent/parse drops a DTEND equal to DTSTART, and stored + // without a duration Stalwart ends the event at 23:59:59. + it('imports a timed event without a duration as lasting zero seconds', () => { + expect(prepareImportedEvent({ uid: 'z', start: '2026-03-02T09:00:00', timeZone: 'Europe/Berlin' }).duration).toBe('PT0S'); + expect(prepareImportedEvent({ uid: 'h', start: '2026-03-02T09:00:00', duration: 'PT1H' }).duration).toBe('PT1H'); + expect(prepareImportedEvent({ uid: 't', '@type': 'Task', start: '2026-03-02T09:00:00' })).not.toHaveProperty('duration'); + }); +}); diff --git a/src/stores/__tests__/calendar-subscriptions-store.test.ts b/src/stores/__tests__/calendar-subscriptions-store.test.ts index 7d827c1c..6eb7f5e3 100644 --- a/src/stores/__tests__/calendar-subscriptions-store.test.ts +++ b/src/stores/__tests__/calendar-subscriptions-store.test.ts @@ -7,21 +7,25 @@ vi.mock('../../api/calendar', () => ({ parseCalendarBlob: vi.fn(), queryEvents: vi.fn(), getEventIdentities: vi.fn(), + getEvents: vi.fn(async () => []), deleteEvents: vi.fn(), updateEvent: vi.fn(), updateCalendar: vi.fn(), })); vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) })); -vi.mock('../../api/jmap-client', () => ({ - jmapClient: { accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org' }, +const client = vi.hoisted(() => ({ + accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org', })); +vi.mock('../../api/jmap-client', () => ({ jmapClient: client })); const calendarStore = vi.hoisted(() => ({ + generation: 0, importEvents: vi.fn(async (_events: unknown[], _calendarId: string) => ({ created: 1, updated: 0 })), fetchCalendars: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), })); vi.mock('../calendar-store', () => ({ useCalendarStore: { getState: () => calendarStore }, + calendarStoreGeneration: () => calendarStore.generation, })); vi.mock('@react-native-async-storage/async-storage', () => ({ default: { @@ -87,7 +91,7 @@ describe('updateSubscription', () => { await useCalendarSubscriptionsStore.getState().updateSubscription('s', { name: 'Holidays', color: '#FF2968FF' }); - expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' }); + expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' }, 'acc-1'); expect(useCalendarSubscriptionsStore.getState().subscriptions[0].name).toBe('Holidays'); }); }); @@ -145,7 +149,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000'); + expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000', undefined, 'acc-1'); expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'e1' })], 'n1'); expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null }); expect(only().lastSyncAt).not.toBeNull(); @@ -196,12 +200,12 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c'); + expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c', 'acc-1'); expect(calendarApi.queryEvents).not.toHaveBeenCalled(); expect(calendarApi.deleteEvents).not.toHaveBeenCalled(); expect(calendarApi.updateEvent).not.toHaveBeenCalled(); // The feed goes into a new mirror, as for an unlinked subscription. - expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000'); + expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000', undefined, 'acc-1'); expect(calendarStore.importEvents.mock.calls.map(([, calendarId]) => calendarId)).toEqual(['n1']); expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null }); }); @@ -228,7 +232,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']); + expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1'], undefined, 'acc-1'); }); it('destroys only a calendar that carries the subscription name on remove', async () => { @@ -238,7 +242,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { await useCalendarSubscriptionsStore.getState().removeSubscription('mine'); expect(calendarApi.deleteCalendar).not.toHaveBeenCalled(); await useCalendarSubscriptionsStore.getState().removeSubscription('mine-2'); - expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c'); + expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c', 'acc-1'); }); it('renames only a calendar that carries the subscription name', async () => { @@ -264,10 +268,10 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { vi.mocked(calendarApi.queryEvents).mockResolvedValue(['x1']); vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([{ id: 'x1', uid: 'gone', calendarIds: { c: true } }] as never); await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); - expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']); + expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1'], undefined, 'acc-1'); expect(calendarApi.createCalendar).not.toHaveBeenCalled(); await useCalendarSubscriptionsStore.getState().removeSubscription('mine'); - expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c'); + expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c', 'acc-1'); }); it('keeps its name when the mirror\'s name cannot be read', async () => { @@ -360,3 +364,87 @@ describe('subscriptions made before they recorded their account', () => { expect(only()).toEqual({ ...legacy, accountId: 'acc-1' }); }); }); + +describe('refreshing a subscription', () => { + const ME = 'user@mail.example.org'; + const sub = (): CalendarSubscription => ({ + id: 'feed', name: 'Holidays', url: 'https://feeds.example/holidays.ics', calendarId: 'b', + accountId: 'acc-1', localAccountId: ME, lastSyncAt: 5, lastError: null, + }); + const only = () => useCalendarSubscriptionsStore.getState().subscriptions[0]; + const feed = [ + { uid: 'kept', title: 'Moved upstream', start: '2027-01-02T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + { uid: 'same', title: 'Unchanged', start: '2027-01-03T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + { uid: 'mine', title: 'Feed copy', start: '2027-01-04T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + { uid: 'new', title: 'New upstream', start: '2027-01-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + client.accountId = 'acc-1'; + calendarStore.generation = 0; + useCalendarSubscriptionsStore.setState({ syncing: {}, subscriptions: [sub()] }); + globalThis.fetch = vi.fn(async () => ({ + ok: true, status: 200, headers: { get: () => null }, text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', + })) as unknown as typeof fetch; + vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Holidays'); + vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue(feed as never); + vi.mocked(calendarApi.queryEvents).mockResolvedValue(['c', 'd', 'e', 'f']); + vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([ + { id: 'c', uid: 'kept', calendarIds: { b: true } }, + { id: 'd', uid: 'same', calendarIds: { b: true } }, + // The user's own event with the feed's UID, also in their calendar. + { id: 'e', uid: 'mine', calendarIds: { b: true, personal: true } }, + { id: 'f', uid: 'gone', calendarIds: { b: true } }, + ] as never); + vi.mocked(calendarApi.getEvents).mockResolvedValue([ + { id: 'c', uid: 'kept', title: 'Before', start: '2027-01-02T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { b: true } }, + { id: 'd', uid: 'same', title: 'Unchanged', start: '2027-01-03T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', status: 'confirmed', calendarIds: { b: true } }, + ] as never); + }); + + it('gives the events still in the feed what it changed and imports only the new ones', async () => { + await useCalendarSubscriptionsStore.getState().syncSubscription('feed'); + + expect(calendarApi.getEvents).toHaveBeenCalledWith(['c', 'd'], 'acc-1'); + expect(calendarApi.updateEvent).toHaveBeenCalledTimes(1); + expect(calendarApi.updateEvent).toHaveBeenCalledWith('c', expect.objectContaining({ + title: 'Moved upstream', start: '2027-01-02T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + }), false, 'acc-1'); + expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['f'], undefined, 'acc-1'); + expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'new' })], 'b'); + expect(only().lastError).toBeNull(); + }); + + it('stops when the client serves another account, whose calendar and events have the same ids', async () => { + // Both accounts have calendar "b" with events "c" to "f". + vi.mocked(calendarApi.queryEvents).mockImplementationOnce(async () => { + client.accountId = 'acc-2'; + calendarStore.generation += 1; + return ['c', 'd', 'e', 'f']; + }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('feed'); + + expect(calendarApi.queryEvents).toHaveBeenCalledWith(['b'], '', '', 'acc-1'); + expect(calendarApi.getEventIdentities).not.toHaveBeenCalled(); + expect(calendarApi.deleteEvents).not.toHaveBeenCalled(); + expect(calendarApi.updateEvent).not.toHaveBeenCalled(); + expect(calendarStore.importEvents).not.toHaveBeenCalled(); + // Not the subscription's failure: it syncs again in its own account. + expect(only()).toMatchObject({ lastSyncAt: 5, lastError: null }); + }); + + it('stops between its writes when the account changes', async () => { + vi.mocked(calendarApi.deleteEvents).mockImplementationOnce(async () => { + calendarStore.generation += 1; + }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('feed'); + + expect(calendarApi.deleteEvents).toHaveBeenCalledTimes(1); + expect(calendarApi.getEvents).not.toHaveBeenCalled(); + expect(calendarApi.updateEvent).not.toHaveBeenCalled(); + expect(calendarStore.importEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 86df2b92..4be8ade0 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -18,6 +18,7 @@ import { queryEvents, getEvents as fetchEvents, getEventIdentities, + searchEventIds, scanCalendarObjects, isCalendarAccessDenied, noteCalendarAccessError, @@ -59,6 +60,7 @@ import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-comp import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache'; import { requestDeviceSync } from '../device-sync/app/request'; import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort'; +import { searchFilter } from '../lib/calendar-search'; import { CALENDAR_AUTHORITY } from '../device-sync/types'; // extendRange merges up to twice the requested range (at least half a year, @@ -141,7 +143,12 @@ export function prepareImportedEvent(event: Partial): Partial void; } -// In-flight dedupe for the two whole-account loads (see fetchCalendars). -let calendarsInFlight: Promise | null = null; +// In-flight dedupe for the two whole-account loads (see loadCalendarList). +let calendarsInFlight: Promise | null = null; let tasksInFlight: Promise | null = null; // One up with every reset (an account switch, a sign-out). A load still under @@ -310,14 +317,94 @@ let tasksInFlight: Promise | null = null; // one still in flight. let storeGeneration = 0; -/** A range load the store's reset overtook: what it read belongs to the account before. */ +/** The store's reset count: work started under an earlier one is the previous account's. */ +export function calendarStoreGeneration(): number { + return storeGeneration; +} + +/** + * A load or write the store's reset overtook: what it read belongs to the + * account before, and what it had still to write would reach the next one. + */ export class CalendarAccountChangedError extends Error { constructor() { - super('The calendar was reset while its events were loading'); + super(t('calendar.account_changed', 'The account changed while the calendar was loading or saving.')); this.name = 'CalendarAccountChangedError'; } } +/** + * The account a write is made for, taken when it starts: the store + * generation and the primary JMAP account the client serves. Calendar and + * event ids repeat across accounts, so a write still under way after an + * account switch must not send its next step, which would reach the next + * account, nor change the store, which shows that account by then. + */ +interface WriteScope { + generation: number; + primaryAccountId: string | null; +} + +function connectedAccountId(): string | null { + return jmapClient.isConnected ? jmapClient.accountId : null; +} + +function writeScope(): WriteScope { + return { generation: storeGeneration, primaryAccountId: connectedAccountId() }; +} + +function inScope(scope: WriteScope): boolean { + return scope.generation === storeGeneration && connectedAccountId() === scope.primaryAccountId; +} + +function assertInScope(scope: WriteScope): void { + if (!inScope(scope)) throw new CalendarAccountChangedError(); +} + +/** + * The JMAP account a write goes to, named on every request: a shared + * calendar's owner, else the primary account the write started in. + */ +function writeAccount(scope: WriteScope, accountId: string | undefined): string | undefined { + return accountId ?? scope.primaryAccountId ?? undefined; +} + +/** + * fetchCalendars: load the calendar list into the store. True when it did; + * false without a session, when the request failed, or when a reset overtook + * it - the range loads then leave their range unloaded, so the next one + * tries again. + */ +function loadCalendarList(): Promise { + // CalendarScreen fires this on mount; on cold start that happens before + // restoreSession has connected jmapClient. Bail rather than surfacing + // a "Not authenticated" error - the refetch driven by the auth-store + // will run this again once the session is live. + if (!jmapClient.isConnected) return Promise.resolve(false); + // First-touch gate (#907): the screen mount, ensureRange and the + // auth-store all kick this off at once, and on a clustered Stalwart + // concurrent first Calendar/* requests can each lazily create a default + // calendar. Share one in-flight request instead. + if (calendarsInFlight) return calendarsInFlight; + const generation = storeGeneration; + const set = useCalendarStore.setState; + calendarsInFlight = (async () => { + try { + const calendars = sortCalendars((await fetchCalendars()) ?? []); + if (generation !== storeGeneration) return false; + set({ calendars }); + return true; + } catch (err) { + if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); + return false; + } finally { + // After a reset the slot belongs to the next account's load. + if (generation === storeGeneration) calendarsInFlight = null; + } + })(); + return calendarsInFlight; +} + function persistHidden(ids: string[]): void { void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch( (err) => console.warn('[calendar-store] persist hidden failed', err), @@ -427,6 +514,41 @@ export async function loadEventsInRange( // here. const knownMasters = new Map(); +/** + * 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 { + const filter = searchFilter(terms); + if (!filter || !jmapClient.isConnected) return []; + const generation = storeGeneration; + const calendars = useCalendarStore.getState().calendars; + const accounts = new Set([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 { storeEvent?: CalendarEvent; /** Raw JMAP id the change is sent to. */ @@ -499,6 +621,7 @@ async function updateBaseEvent( updates: Record, sendSchedulingMessages: boolean | undefined, accountId: string | undefined, + scope: WriteScope, ): Promise { const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides'); if (writesOverrides && overrideSeriesKeysRejected) { @@ -510,6 +633,7 @@ async function updateBaseEvent( } catch (err) { if (!writesOverrides || !isOverrideSeriesKeyRejection(err)) throw err; overrideSeriesKeysRejected = true; + assertInScope(scope); await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId); } } @@ -528,6 +652,7 @@ async function updateOccurrence( patchFor: (options: OverridePatchOptions) => Partial, sendSchedulingMessages: boolean | undefined, accountId: string | undefined, + scope: WriteScope, ): Promise { if (!syntheticIdRejected) { try { @@ -537,10 +662,11 @@ async function updateOccurrence( if (!isSyntheticIdMutationUnsupported(err)) throw err; syntheticIdRejected = true; } + assertInScope(scope); } const fallback = buildFallbackOverridePatch(instance, patchFor({ seriesKeys: true })); if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to override'); - await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId); + await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId, scope); } /** @@ -598,11 +724,14 @@ async function updateOccurrenceInTwoSteps( patch: Partial, sendSchedulingMessages: boolean | undefined, accountId: string | undefined, + scope: WriteScope, ): Promise { - await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId); + await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId, scope); + assertInScope(scope); const current = await findServerOccurrence(instance, accountId).catch(() => null); + assertInScope(scope); if (!current) throw new OccurrenceNotFoundAgainError(); - await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId); + await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId, scope); } /** @@ -615,8 +744,9 @@ async function updateOccurrenceInTwoSteps( * never shown. 0.16 stores and shows it either way; its series keeps the * same alerts. */ -async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined): Promise { +async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined, scope: WriteScope): Promise { const [base] = await fetchEvents([baseId], accountId); + assertInScope(scope); if (!base?.useDefaultAlerts) return; const shown = base.alerts ?? {}; await apiUpdateEvent(baseId, { @@ -632,6 +762,7 @@ async function destroyOccurrence( syntheticId: string, sendSchedulingMessages: boolean | undefined, accountId: string | undefined, + scope: WriteScope, ): Promise { if (!syntheticIdRejected) { try { @@ -641,6 +772,7 @@ async function destroyOccurrence( if (!isSyntheticIdMutationUnsupported(err)) throw err; syntheticIdRejected = true; } + assertInScope(scope); } const fallback = buildFallbackExcludePatch(instance); if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to exclude'); @@ -656,11 +788,12 @@ async function updateBrowserOccurrence( target: MutationTarget, updates: Partial, sendSchedulingMessages: boolean | undefined, + scope: WriteScope, ): Promise { const occurrence = target.storeEvent!; const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true })); if (!patch) throw new Error('Cannot resolve the occurrence to override'); - await updateBaseEvent(target.realId, patch, sendSchedulingMessages, target.accountId); + await updateBaseEvent(target.realId, patch, sendSchedulingMessages, writeAccount(scope, target.accountId), scope); } /** @@ -673,21 +806,25 @@ async function updateBrowserOccurrenceInTwoSteps( target: MutationTarget, updates: Partial, sendSchedulingMessages: boolean | undefined, + scope: WriteScope, ): Promise { const steps = buildOverrideInTwoSteps(target.storeEvent!, updates); if (!steps) throw new Error('Cannot resolve the occurrence to override'); - await updateBaseEvent(target.realId, steps.quiet, false, target.accountId); - await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, target.accountId); + const accountId = writeAccount(scope, target.accountId); + await updateBaseEvent(target.realId, steps.quiet, false, accountId, scope); + assertInScope(scope); + await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, accountId, scope); } /** Delete one occurrence the device expanded by excluding it on its base event. */ async function destroyBrowserOccurrence( target: MutationTarget, sendSchedulingMessages: boolean | undefined, + scope: WriteScope, ): Promise { const patch = buildFallbackExcludePatch(target.storeEvent!); if (!patch) throw new Error('Cannot resolve the occurrence to exclude'); - await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, target.accountId); + await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, writeAccount(scope, target.accountId)); } /** @@ -732,29 +869,7 @@ export const useCalendarStore = create()( }, fetchCalendars: async () => { - // CalendarScreen fires this on mount; on cold start that happens before - // restoreSession has connected jmapClient. Bail rather than surfacing - // a "Not authenticated" error - the refetch driven by the auth-store - // will run this again once the session is live. - if (!jmapClient.isConnected) return; - // First-touch gate (#907): the screen mount, ensureRange and the - // auth-store all kick this off at once, and on a clustered Stalwart - // concurrent first Calendar/* requests can each lazily create a default - // calendar. Share one in-flight request instead. - if (calendarsInFlight) return calendarsInFlight; - const generation = storeGeneration; - calendarsInFlight = (async () => { - try { - const calendars = sortCalendars((await fetchCalendars()) ?? []); - if (generation === storeGeneration) set({ calendars }); - } catch (err) { - if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); - } finally { - // After a reset the slot belongs to the next account's load. - if (generation === storeGeneration) calendarsInFlight = null; - } - })(); - return calendarsInFlight; + await loadCalendarList(); }, fetchEvents: async (calendarIds, after, before) => { @@ -827,11 +942,19 @@ export const useCalendarStore = create()( const { loadedRange } = get(); if (loadedRange && rangeCovers(loadedRange, after, before)) return; + // Taken before the calendars load: a reset meanwhile (an account switch) + // makes this range the previous account's. + const generation = storeGeneration; + if (!jmapClient.isConnected) return; + // Queries are windowed now, so load exactly the requested range instead // of an ever-growing union (which re-downloaded everything each time). if (get().calendars.length === 0) { // Calendars haven't loaded yet - fetch them (deduped), then events. - await get().fetchCalendars(); + // A list that didn't load (no session any more, a failed request) + // leaves the range unloaded: marked loaded, it stayed empty. + const listed = await loadCalendarList(); + if (generation !== storeGeneration || !listed) return; } const calendarIds = get().calendars.map((c) => c.id); if (calendarIds.length === 0) { @@ -840,6 +963,7 @@ export const useCalendarStore = create()( } const needTasks = get().tasks.length === 0 && get().taskOnlyCalendarIds.length === 0; await get().fetchEvents(calendarIds, after, before); + if (generation !== storeGeneration) return; if (needTasks) void get().fetchTasks(); }, @@ -856,17 +980,22 @@ export const useCalendarStore = create()( await get().ensureRange(after, before); return; } + // Taken before the calendars load, as in ensureRange. + const generation = storeGeneration; if (!jmapClient.isConnected) return; // Calendars haven't loaded yet (cold start with a cached window): fetch // them (deduped) first, like ensureRange. - if (get().calendars.length === 0) await get().fetchCalendars(); + if (get().calendars.length === 0) { + const listed = await loadCalendarList(); + if (generation !== storeGeneration || !listed) return; + } const calendars = get().calendars; const calendarIds = calendars.map((c) => c.id); if (calendarIds.length === 0) { - set({ loadedRange: plan.union }); + // Only the window this plan grew from: a jump elsewhere replaced it. + if (sameRange(get().loadedRange, loaded)) set({ loadedRange: plan.union }); return; } - const generation = storeGeneration; set({ loading: true, error: null }); try { const incoming: CalendarEvent[] = []; @@ -931,6 +1060,7 @@ export const useCalendarStore = create()( // Shared calendars live in the owner's account — route the create there, // and against the calendar's raw server id (calendarId is the namespaced // store id for shared calendars). + const scope = writeScope(); const calendars = get().calendars; const cal = calendars.find((c) => c.id === calendarId); const accountId = cal?.accountId; @@ -940,19 +1070,22 @@ export const useCalendarStore = create()( event, cal?.originalId || calendarId, schedule, - accountId, + writeAccount(scope, accountId), ); + // Made; the store shows another account by now. + if (!inScope(scope)) return created; // The /set echo lacks server-computed properties (utcStart/utcEnd, the // normalised recurrence rule); re-read the event so it renders at the // right instant, then expand a recurring series across the loaded range // instead of showing a single instance until the next refresh. let full = created; try { - const fetched = (await fetchEvents([created.id], accountId)) ?? []; + const fetched = (await fetchEvents([created.id], writeAccount(scope, accountId))) ?? []; if (fetched[0]) full = fetched[0]; } catch { // Keep the echo; the next refresh reconciles. } + if (!inScope(scope)) return full; // Map the same way fetchEvents does so the optimistic insert doesn't // collide with the user's own events and stays visible under the right // calendar filter. @@ -971,8 +1104,10 @@ export const useCalendarStore = create()( // A change to an occurrence stays on that occurrence: through its // synthetic id, or as an override on the base event it was expanded // from. Everything else goes to the stored event. + const scope = writeScope(); const target = resolveMutationTarget(get().events, id, 'occurrence'); const { storeEvent, realId, accountId } = target; + const account = writeAccount(scope, accountId); // Remap namespaced (shared-calendar) store ids in calendarIds back to the // raw server ids the owning account knows. const patch: Record = { ...changes }; @@ -1009,15 +1144,16 @@ export const useCalendarStore = create()( : storeEvent.participants); if (target.isOccurrence && storeEvent) { if (stopsDefaultAlerts && storeEvent.baseEventId) { - await stopSeriesDefaultAlerts(storeEvent.baseEventId, accountId); + await stopSeriesDefaultAlerts(storeEvent.baseEventId, account, scope); + assertInScope(scope); } if (twoSteps) { try { - await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial, schedule, accountId); + await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial, schedule, account, scope); } catch (err) { // Its quiet override is on the server, and the series' occurrences // may have other ids now: a retry must not go to the ones in view. - if (err instanceof OccurrenceNotFoundAgainError) await get().refresh().catch(() => undefined); + if (err instanceof OccurrenceNotFoundAgainError && inScope(scope)) await get().refresh().catch(() => undefined); throw err; } } else { @@ -1026,19 +1162,25 @@ export const useCalendarStore = create()( realId, (options) => withNewOverrideDetails(storeEvent, patch as Partial, options), schedule, - accountId, + account, + scope, ); } } else if (target.isBrowserOccurrence) { - if (stopsDefaultAlerts) await stopSeriesDefaultAlerts(realId, accountId); + if (stopsDefaultAlerts) { + await stopSeriesDefaultAlerts(realId, account, scope); + assertInScope(scope); + } if (twoSteps) { - await updateBrowserOccurrenceInTwoSteps(target, patch as Partial, schedule); + await updateBrowserOccurrenceInTwoSteps(target, patch as Partial, schedule, scope); } else { - await updateBrowserOccurrence(target, patch as Partial, schedule); + await updateBrowserOccurrence(target, patch as Partial, schedule, scope); } } else { - await updateBaseEvent(realId, patch, schedule, accountId); + await updateBaseEvent(realId, patch, schedule, account, scope); } + // Saved; the store shows another account by now, whose events can carry the same ids. + if (!inScope(scope)) return; set({ events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial) } : e)), }); @@ -1056,19 +1198,24 @@ export const useCalendarStore = create()( deleteEvent: async (id) => { // Deleting an occurrence removes just that one: through its synthetic // id, or by excluding it on the base event it was expanded from. + const scope = writeScope(); const target = resolveMutationTarget(get().events, id, 'occurrence'); const { storeEvent, realId, accountId } = target; const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, realId, accountId); const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined; if (target.isOccurrence && storeEvent) { - await destroyOccurrence(storeEvent, realId, schedule, accountId); + await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope); + if (!inScope(scope)) return; set({ events: get().events.filter((e) => e.id !== id) }); } else if (target.isBrowserOccurrence) { - await destroyBrowserOccurrence(target, schedule); + await destroyBrowserOccurrence(target, schedule, scope); + if (!inScope(scope)) return; set({ events: get().events.filter((e) => e.id !== id) }); } else { - await apiDeleteEvents([realId], schedule, accountId); + await apiDeleteEvents([realId], schedule, writeAccount(scope, accountId)); + // Deleted; the store shows another account by now, whose events can carry the same ids. + if (!inScope(scope)) return; knownMasters.delete(id); // Destroying a master removes every expanded occurrence of it, not // just the tapped one. @@ -1094,7 +1241,10 @@ export const useCalendarStore = create()( // and its SEQUENCE - a change that raises it from a stale one goes out // lower than the attendees' copies, which 0.16 refuses. const realId = seriesIdOf(event); + const generation = storeGeneration; const fetched = (await fetchEvents([realId], event.accountId)) ?? []; + // Read from the account before a reset: not the next account's master. + if (generation !== storeGeneration) throw new CalendarAccountChangedError(); const master = fetched[0]; if (!master) return null; // Known from here on, so a change to it is sent to its own account under @@ -1114,10 +1264,12 @@ export const useCalendarStore = create()( // isn't in the store; the caller hands it over instead. With scope // 'series' an occurrence answers for its whole series: the stored event // is updated. + const write = writeScope(); const target = resolveMutationTarget(get().events, eventId, scope); const storeEvent = target.storeEvent ?? event; const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event); const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId; + const account = writeAccount(write, accountId); const occurrence = scope === 'occurrence' && (target.isOccurrence || target.isBrowserOccurrence) ? target.storeEvent ?? null : null; @@ -1131,9 +1283,9 @@ export const useCalendarStore = create()( const patch = patchFor({ seriesKeys: true }); if (!patch) throw new Error('Participant not found on this occurrence'); if (target.isOccurrence) { - await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, accountId); + await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, account, write); } else { - await updateBrowserOccurrence(target, patch, true); + await updateBrowserOccurrence(target, patch, true, write); } } else { // Repair events that are missing the organizer (e.g. imported ones) so @@ -1142,8 +1294,10 @@ export const useCalendarStore = create()( replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress ? replyTo.imip : undefined; - await apiRsvpEvent(realId, participantId, status, repair, accountId); + await apiRsvpEvent(realId, participantId, status, repair, account); } + // Answered; the store shows another account by now, whose events can carry the same ids. + if (!inScope(write)) return; requestDeviceSync(CALENDAR_AUTHORITY); set({ events: get().events.map((e) => { @@ -1163,11 +1317,12 @@ export const useCalendarStore = create()( importEvents: async (events, calendarId) => { if (events.length === 0) return { imported: 0, refused: [] }; + const scope = writeScope(); // Shared calendars live in the owner's account and carry a namespaced // store id — resolve the raw server id + owning account so dedup and // create target the right place. const cal = get().calendars.find((c) => c.id === calendarId); - const accountId = cal?.accountId; + const accountId = writeAccount(scope, cal?.accountId); const serverCalendarId = cal?.originalId || calendarId; const refused: RefusedImport[] = []; // Stalwart enforces UID uniqueness across calendars (#113): @@ -1178,7 +1333,9 @@ export const useCalendarStore = create()( let linked = 0; try { const existingIds = await queryEvents([], '', '', accountId); + assertInScope(scope); const existing = await getEventIdentities(existingIds, accountId); + assertInScope(scope); const byUid = new Map(); for (const e of existing) if (e.uid) byUid.set(e.uid, e); const fresh: Partial[] = []; @@ -1189,6 +1346,7 @@ export const useCalendarStore = create()( continue; } if (found.calendarIds?.[serverCalendarId]) continue; + assertInScope(scope); try { await apiUpdateEvent( found.id, @@ -1203,7 +1361,8 @@ export const useCalendarStore = create()( } } toCreate = fresh; - } catch { + } catch (err) { + if (err instanceof CalendarAccountChangedError) throw err; // Couldn't dedupe — proceed and let the server reject genuine dupes. } const prepared = toCreate.map(prepareImportedEvent); @@ -1211,6 +1370,8 @@ export const useCalendarStore = create()( try { // Batch in chunks of 50 to avoid oversized requests. for (let i = 0; i < prepared.length; i += 50) { + // The rest would go to the next account's calendar of the same id. + assertInScope(scope); try { const result = await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId); count += result.created; @@ -1226,7 +1387,7 @@ export const useCalendarStore = create()( } } finally { // Also when a chunk was refused: show what did get in. - if (count > 0 || linked > 0) { + if ((count > 0 || linked > 0) && inScope(scope)) { requestDeviceSync(CALENDAR_AUTHORITY); await get().refresh(); } @@ -1236,15 +1397,20 @@ export const useCalendarStore = create()( }, createCalendar: async (name, color, description) => { - const created = await apiCreateCalendar(name, color, description); + const scope = writeScope(); + const created = await apiCreateCalendar(name, color, description, writeAccount(scope, undefined)); + // Made in the account before a switch: not one of the next account's. + if (!inScope(scope)) return created; set({ calendars: sortCalendars([...get().calendars, created]) }); requestDeviceSync(CALENDAR_AUTHORITY); return created; }, updateCalendar: async (id, updates) => { + const scope = writeScope(); const cal = get().calendars.find((c) => c.id === id); - await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId); + await apiUpdateCalendar(cal?.originalId || id, updates, writeAccount(scope, cal?.accountId)); + if (!inScope(scope)) return; set({ calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))), }); @@ -1254,6 +1420,7 @@ export const useCalendarStore = create()( reorderCalendars: async (orderedIds) => { const updates = sortOrderUpdates(get().calendars, orderedIds); if (updates.length === 0) return; + const scope = writeScope(); const before = get().calendars; const withSortOrders = (sortOrders: Map) => sortCalendars(before.map((c) => sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c)); @@ -1262,23 +1429,28 @@ export const useCalendarStore = create()( const saved = new Map(); try { for (const { id, sortOrder } of updates) { + // The rest would renumber the next account's calendars of the same ids. + assertInScope(scope); const cal = before.find((c) => c.id === id); - await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, cal?.accountId); + await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, writeAccount(scope, cal?.accountId)); saved.set(id, sortOrder); } } catch (err) { // Show the order the server now holds: the moves saved before the - // failure stay, the rest are undone. - set({ calendars: withSortOrders(saved) }); + // failure stay, the rest are undone. Not over the next account's list. + if (inScope(scope)) set({ calendars: withSortOrders(saved) }); throw err; } finally { - if (saved.size > 0) requestDeviceSync(CALENDAR_AUTHORITY); + if (saved.size > 0 && inScope(scope)) requestDeviceSync(CALENDAR_AUTHORITY); } }, removeCalendar: async (id) => { + const scope = writeScope(); const cal = get().calendars.find((c) => c.id === id); - await apiDeleteCalendar(cal?.originalId || id, cal?.accountId); + await apiDeleteCalendar(cal?.originalId || id, writeAccount(scope, cal?.accountId)); + // Removed; the next account may have a calendar of the same id. + if (!inScope(scope)) return; set({ calendars: get().calendars.filter((c) => c.id !== id), events: get().events.filter((e) => !e.calendarIds?.[id]), @@ -1289,9 +1461,10 @@ export const useCalendarStore = create()( }, clearCalendarEvents: async (id) => { + const scope = writeScope(); const cal = get().calendars.find((c) => c.id === id); try { - const cleared = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId); + const cleared = await apiClearCalendarEvents(cal?.originalId || id, writeAccount(scope, cal?.accountId)); requestDeviceSync(CALENDAR_AUTHORITY); return cleared; } finally { @@ -1301,8 +1474,10 @@ export const useCalendarStore = create()( }, shareCalendar: async (id, principalId, rights) => { + const scope = writeScope(); const cal = get().calendars.find((c) => c.id === id); - await apiSetCalendarShare(cal?.originalId || id, principalId, rights, cal?.accountId); + await apiSetCalendarShare(cal?.originalId || id, principalId, rights, writeAccount(scope, cal?.accountId)); + if (!inScope(scope)) return; set({ calendars: get().calendars.map((c) => { if (c.id !== id) return c; @@ -1315,8 +1490,10 @@ export const useCalendarStore = create()( }, setDefaultCalendar: async (id) => { + const scope = writeScope(); const cal = get().calendars.find((c) => c.id === id); - await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId); + await apiSetDefaultCalendar(cal?.originalId || id, writeAccount(scope, cal?.accountId)); + if (!inScope(scope)) return; set({ calendars: get().calendars.map((c) => { if (c.id === id) return { ...c, isDefault: true }; @@ -1331,20 +1508,24 @@ export const useCalendarStore = create()( }, createTask: async (task, calendarId) => { + const scope = writeScope(); const cal = get().calendars.find((c) => c.id === calendarId); await apiCreateEvent( { ...task, '@type': 'Task' }, cal?.originalId || calendarId, undefined, - cal?.accountId, + writeAccount(scope, cal?.accountId), ); await get().fetchTasks(); }, updateTask: async (id, changes) => { + const scope = writeScope(); const task = get().tasks.find((t) => t.id === id); const realId = task?.originalId || id; - await apiUpdateEvent(realId, changes, undefined, task?.accountId); + await apiUpdateEvent(realId, changes, undefined, writeAccount(scope, task?.accountId)); + // Saved; the next account's tasks can carry the same ids. + if (!inScope(scope)) return; set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) }); }, @@ -1362,19 +1543,23 @@ export const useCalendarStore = create()( // Flip the checkbox right away and put it back if the server refuses. const patchTask = (changes: Partial) => set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) }); + const scope = writeScope(); patchTask(next); try { - await apiUpdateEvent(task.originalId || id, next, undefined, task.accountId); + await apiUpdateEvent(task.originalId || id, next, undefined, writeAccount(scope, task.accountId)); } catch (err) { - patchTask({ progress: task.progress, percentComplete: task.percentComplete }); + // Not on the next account's task of the same id. + if (inScope(scope)) patchTask({ progress: task.progress, percentComplete: task.percentComplete }); throw err; } }, deleteTask: async (id) => { + const scope = writeScope(); const task = get().tasks.find((t) => t.id === id); const realId = task?.originalId || id; - await apiDeleteEvents([realId], undefined, task?.accountId); + await apiDeleteEvents([realId], undefined, writeAccount(scope, task?.accountId)); + if (!inScope(scope)) return; set({ tasks: get().tasks.filter((t) => t.id !== id) }); }, diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts index 2de807ca..fad85615 100644 --- a/src/stores/calendar-subscriptions-store.ts +++ b/src/stores/calendar-subscriptions-store.ts @@ -9,6 +9,7 @@ import { parseCalendarBlob, queryEvents, getEventIdentities, + getEvents, deleteEvents, updateEvent, updateCalendar, @@ -16,9 +17,10 @@ import { import { jmapClient } from '../api/jmap-client'; import { uploadBytes } from '../api/blob'; import { generateAccountId } from '../lib/account-utils'; +import { feedChanged, feedFields } from '../lib/calendar-feed-diff'; import { readServerPrimaryAccountId } from '../lib/server-fingerprint'; import { useAccountStore } from './account-store'; -import { useCalendarStore } from './calendar-store'; +import { calendarStoreGeneration, useCalendarStore } from './calendar-store'; import { t } from './locale-store'; export interface CalendarSubscription { @@ -118,9 +120,9 @@ export function currentLocalAccountId(): string | null { // A calendar under the recorded id with another name is not the mirror: the // id may be reused by a replaced server, or the user renamed the calendar. // False when the server can't be asked. -async function isMirrorCalendar(calendarId: string, name: string): Promise { +async function isMirrorCalendar(calendarId: string, name: string, accountId: string): Promise { try { - return (await getCalendarName(calendarId)) === name; + return (await getCalendarName(calendarId, accountId)) === name; } catch { return false; } @@ -177,7 +179,39 @@ function base64(input: string): string { return input; } -async function fetchAndParseFeed(url: string): Promise[]> { +/** + * The account a sync runs for, taken when it starts: the JMAP account the + * client serves and the calendar store's reset count. Calendar and event ids + * repeat across accounts (two accounts can both have calendar "b" with events + * "c" and "d"), so a sync still under way after an account switch must stop + * before its next request: it would drop the next account's events and + * import the feed into it. + */ +interface SyncScope { + accountId: string; + generation: number; +} + +function syncScope(): SyncScope | null { + const accountId = currentAccountId(); + return accountId ? { accountId, generation: calendarStoreGeneration() } : null; +} + +/** The account changed since the sync started. */ +class SubscriptionAccountChangedError extends Error { + constructor() { + super(t('calendar.subscription.account_changed', 'The account changed while the subscription was syncing. It syncs again in its own account.')); + this.name = 'SubscriptionAccountChangedError'; + } +} + +function assertScope(scope: SyncScope): void { + if (currentAccountId() !== scope.accountId || calendarStoreGeneration() !== scope.generation) { + throw new SubscriptionAccountChangedError(); + } +} + +async function fetchAndParseFeed(url: string, scope: SyncScope): Promise[]> { const { url: feedUrl, headers } = normalizeFeedUrl(url); const res = await fetch(feedUrl, { headers }); if (!res.ok) { @@ -191,41 +225,98 @@ async function fetchAndParseFeed(url: string): Promise[]> if (!/BEGIN:VCALENDAR/i.test(text)) { throw new Error(t('calendar.subscription.not_a_feed', 'That URL did not return an iCalendar feed')); } + assertScope(scope); const bytes = new TextEncoder().encode(text); - const { blobId } = await uploadBytes(bytes, 'text/calendar'); - const events = await parseCalendarBlob(blobId); + const { blobId } = await uploadBytes(bytes, 'text/calendar', scope.accountId); + assertScope(scope); + const events = await parseCalendarBlob(blobId, scope.accountId); + assertScope(scope); return events.filter((e) => !!e.start); } -// Reconcile the mirror calendar with the remote feed: drop events whose UID -// disappeared upstream (unlinking rather than deleting when the event also -// lives in another calendar), then import the current set (importEvents -// dedupes by UID, so unchanged events are left in place). Callers make sure -// `calendarId` is the mirror: nothing goes into, nor is dropped from, another -// calendar. One made for this sync has nothing to drop. -async function syncFeedIntoCalendar(calendarId: string, url: string, dropStale: boolean): Promise { - const parsed = await fetchAndParseFeed(url); +/** + * Reconcile the mirror calendar with the remote feed. With `linkedBefore` + * (the mirror existed before this sync): drop events whose UID disappeared + * upstream (unlinking rather than deleting when the event also lives in + * another calendar), and give the events still in the feed what it changed + * (time, title, place, recurrence; see lib/calendar-feed-diff). Only those in + * the mirror alone: one also linked to another calendar is the user's own + * event with the same UID and stays. Then import the events the mirror lacks + * (importEvents links a UID another calendar holds). Callers make sure + * `calendarId` is the mirror: nothing goes into, nor is dropped from, + * another calendar. One made for this sync has nothing to drop or update. + * Every request names the account the sync started in, and the sync stops + * once the client serves another (SubscriptionAccountChangedError). + */ +async function syncFeedIntoCalendar( + calendarId: string, + url: string, + linkedBefore: boolean, + scope: SyncScope, +): Promise { + const { accountId } = scope; + const parsed = await fetchAndParseFeed(url, scope); + let toImport = parsed; + let wrote = false; - if (dropStale) { + if (linkedBefore) { const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]); try { - const ids = await queryEvents([calendarId], '', ''); - const existing = await getEventIdentities(ids); + const ids = await queryEvents([calendarId], '', '', accountId); + assertScope(scope); + const existing = await getEventIdentities(ids, accountId); + assertScope(scope); + const inMirror = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]); + toImport = parsed.filter((e) => !e.uid || !inMirror.has(e.uid)); + const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid)); const toDelete: string[] = []; for (const e of stale) { const others = { ...(e.calendarIds || {}) }; delete others[calendarId]; - if (Object.keys(others).length === 0) toDelete.push(e.id); - else await updateEvent(e.id, { calendarIds: others }); + if (Object.keys(others).length === 0) { + toDelete.push(e.id); + } else { + await updateEvent(e.id, { calendarIds: others }, undefined, accountId); + wrote = true; + assertScope(scope); + } } - if (toDelete.length > 0) await deleteEvents(toDelete); - } catch { + if (toDelete.length > 0) { + await deleteEvents(toDelete, undefined, accountId); + wrote = true; + assertScope(scope); + } + + const kept = existing.filter((e) => + e.uid && parsedUids.has(e.uid) && Object.keys(e.calendarIds || {}).length === 1); + if (kept.length > 0) { + const stored = await getEvents(kept.map((e) => e.id), accountId); + assertScope(scope); + const byUid = new Map(stored.filter((e) => e.uid).map((e) => [e.uid!, e])); + for (const incoming of parsed) { + const current = incoming.uid ? byUid.get(incoming.uid) : undefined; + if (!current || !feedChanged(incoming, current)) continue; + byUid.delete(incoming.uid!); + try { + await updateEvent(current.id, feedFields(incoming), false, accountId); + wrote = true; + } catch { + // The next refresh tries again; the rest of the feed goes on. + } + assertScope(scope); + } + } + } catch (err) { + if (err instanceof SubscriptionAccountChangedError) throw err; // Non-fatal: if we can't enumerate existing events, still import new ones. } } - await useCalendarStore.getState().importEvents(parsed, calendarId); + assertScope(scope); + // importEvents shows what it brought in; deletions and changes alone need a reload. + if (toImport.length > 0) await useCalendarStore.getState().importEvents(toImport, calendarId); + else if (wrote) await useCalendarStore.getState().refresh(); } export const useCalendarSubscriptionsStore = create()( @@ -235,32 +326,44 @@ export const useCalendarSubscriptionsStore = create()( syncing: {}, addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => { + const scope = syncScope(); + const localAccountId = currentLocalAccountId(); // Create a dedicated local calendar to hold the feed's events. - const calendar = await createCalendar(name, color); + const calendar = await createCalendar(name, color, undefined, scope?.accountId); const sub: CalendarSubscription = { id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, name, url: url.trim(), color, calendarId: calendar.id, - accountId: currentAccountId() ?? undefined, - localAccountId: currentLocalAccountId() ?? undefined, + accountId: scope?.accountId, + localAccountId: localAccountId ?? undefined, refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES, lastSyncAt: null, lastError: null, }; - // Make the new calendar visible immediately. - await useCalendarStore.getState().fetchCalendars(); try { + if (!scope) throw new SubscriptionAccountChangedError(); + assertScope(scope); + // Make the new calendar visible immediately. + await useCalendarStore.getState().fetchCalendars(); set({ syncing: { ...get().syncing, [sub.id]: true } }); - await syncFeedIntoCalendar(calendar.id, sub.url, false); + await syncFeedIntoCalendar(calendar.id, sub.url, false, scope); set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] }); await useCalendarStore.getState().refresh(); return { ...sub, lastSyncAt: Date.now() }; } catch (err) { + if (err instanceof SubscriptionAccountChangedError) { + // The mirror is in the account the sync started in, which the + // client no longer serves: a request to remove it would reach + // the next account. Kept as that account's subscription, it + // fills its mirror on that account's next sync. + set({ subscriptions: [...get().subscriptions, sub] }); + throw err; + } // Roll back: a bad URL / 404 must not leave a phantom calendar behind. try { - await deleteCalendar(calendar.id); + await deleteCalendar(calendar.id, scope?.accountId); } catch { // best-effort } @@ -289,13 +392,16 @@ export const useCalendarSubscriptionsStore = create()( // changes: a sync recognises the mirror by the subscription's name, // and would take the old-named calendar for another one. if (sub.calendarId && (next.name !== sub.name || next.color !== sub.color)) { - if ((await getCalendarName(sub.calendarId)) === sub.name) { + // Without a session this throws, as the name can't be read. + const scope = syncScope(); + if ((await getCalendarName(sub.calendarId, scope?.accountId)) === sub.name && scope) { + assertScope(scope); // Only what changed: Stalwart 1.0 refuses a colour that is not // #rgb, #rrggbb or a CSS name, which another client may have set. await updateCalendar(sub.calendarId, { ...(next.name !== sub.name ? { name: next.name } : {}), ...(next.color !== sub.color ? { color: next.color } : {}), - }); + }, scope.accountId); void useCalendarStore.getState().fetchCalendars().catch(() => undefined); } } @@ -308,9 +414,11 @@ export const useCalendarSubscriptionsStore = create()( set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) }); // Only a calendar recognisably the mirror is destroyed, with its // events: under a replaced server the recorded id can name another. - if (sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name))) { + const scope = syncScope(); + if (scope && sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name, scope.accountId))) { try { - await deleteCalendar(sub.calendarId); + assertScope(scope); + await deleteCalendar(sub.calendarId, scope.accountId); } catch { // best-effort } @@ -328,12 +436,16 @@ export const useCalendarSubscriptionsStore = create()( const localAccountId = currentLocalAccountId(); if (sub.localAccountId && sub.localAccountId !== localAccountId) return; if (!sub.calendarId && !localAccountId) return; + const scope = syncScope(); + if (!scope) return; set({ syncing: { ...get().syncing, [id]: true } }); try { - const jmapAccountId = currentAccountId(); + const jmapAccountId = scope.accountId; let calendarId = sub.calendarId; // Throws when the server can't be asked; the next sync tries again. - if (calendarId && (await getCalendarName(calendarId)) !== sub.name) { + const mirrorName = calendarId ? await getCalendarName(calendarId, jmapAccountId) : null; + assertScope(scope); + if (calendarId && mirrorName !== sub.name) { // The recorded id names another calendar, or none: the server was // replaced unseen, or the mirror was renamed or removed elsewhere. // The feed goes into its own mirror only: a new one, made as for @@ -343,23 +455,25 @@ export const useCalendarSubscriptionsStore = create()( && !(localAccountId && (await ownsLegacySubscription(sub, localAccountId, [jmapAccountId])))) { return; } + assertScope(scope); calendarId = null; } else if (calendarId && !sub.localAccountId && localAccountId) { // Its mirror is in this account, so it is this account's alone // from now on, not every account's with the same JMAP id. - const owner = { localAccountId, accountId: sub.accountId ?? jmapAccountId ?? undefined }; + const owner = { localAccountId, accountId: sub.accountId ?? jmapAccountId }; set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...owner } : s)) }); } if (!calendarId) { // As addSubscription does. Linked right away, so a feed that // fails below doesn't get another calendar on the next sync. - calendarId = (await createCalendar(sub.name, sub.color)).id; - const linked = { calendarId, accountId: jmapAccountId ?? undefined, localAccountId: localAccountId ?? undefined }; + calendarId = (await createCalendar(sub.name, sub.color, undefined, jmapAccountId)).id; + const linked = { calendarId, accountId: jmapAccountId, localAccountId: localAccountId ?? undefined }; set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...linked } : s)) }); + assertScope(scope); await useCalendarStore.getState().fetchCalendars(); } const linkedBefore = calendarId === sub.calendarId; - await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore); + await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore, scope); set({ subscriptions: get().subscriptions.map((s) => s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s, @@ -367,6 +481,9 @@ export const useCalendarSubscriptionsStore = create()( }); if (!linkedBefore) await useCalendarStore.getState().refresh(); } catch (err) { + // Not this subscription's failure: the client serves another + // account now, and the sync runs again in its own. + if (err instanceof SubscriptionAccountChangedError) return; const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription'); set({ subscriptions: get().subscriptions.map((s) =>