From e6a89ad07ae8af7e06c7c9ead0aff7e4bd8db0c5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:22:59 +0100 Subject: [PATCH] feat: show invited participants' free/busy in the event editor --- .../__tests__/calendar-availability.test.ts | 213 ++++++++++++ .../__tests__/live-stalwart-free-busy.test.ts | 157 +++++++++ src/api/calendar-availability.ts | 322 ++++++++++++++++++ src/components/calendar/EventModal.tsx | 9 + .../calendar/ParticipantAvailability.tsx | 178 ++++++++++ .../participant-availability-wiring.test.ts | 21 ++ src/lib/calendar-free-busy.ts | 92 +++++ 7 files changed, 992 insertions(+) create mode 100644 src/api/__tests__/calendar-availability.test.ts create mode 100644 src/api/__tests__/live-stalwart-free-busy.test.ts create mode 100644 src/api/calendar-availability.ts create mode 100644 src/components/calendar/ParticipantAvailability.tsx create mode 100644 src/components/calendar/__tests__/participant-availability-wiring.test.ts create mode 100644 src/lib/calendar-free-busy.ts diff --git a/src/api/__tests__/calendar-availability.test.ts b/src/api/__tests__/calendar-availability.test.ts new file mode 100644 index 00000000..a39b9685 --- /dev/null +++ b/src/api/__tests__/calendar-availability.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'u', + request: vi.fn(), + currentSession: { apiUrl: 'https://mail.example/jmap/' } as unknown, + hasCapability: vi.fn(() => true), + getAccountCapability: vi.fn(() => ({ currentUserPrincipalId: 'u' })), + getMaxCallsInRequest: vi.fn(() => 16), + }, +})); + +vi.mock('../files', () => ({ + listPrincipals: vi.fn(), +})); + +import { jmapClient } from '../jmap-client'; +import { listPrincipals } from '../files'; +import { + checkAvailabilityAccess, + getPrincipalsAvailability, + lookUpAvailabilityStatuses, + mayGetAvailability, + resetAvailabilityCaches, + supportsAvailability, +} from '../calendar-availability'; +import { editorAvailabilityWindow, availabilityFor } from '../../lib/calendar-free-busy'; + +// Ported from the webmail's lib/jmap/__tests__/free-busy.test.ts and the +// availability cases of components/calendar/__tests__/participant-availability.test.tsx. + +type Call = [string, Record, string]; +const mockRequest = jmapClient.request as ReturnType; +const mockHasCapability = jmapClient.hasCapability as ReturnType; +const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType; +const mockListPrincipals = listPrincipals as ReturnType; + +const DIRECTORY = { + status: 'ok', + principals: [ + { id: 'a', type: 'individual', name: 'Alice', email: 'alice@example.org' }, + { id: 'b', type: 'individual', name: 'Bob', email: 'Bob@example.org' }, + { id: 'c', type: 'individual', name: 'Carol', email: 'carol@example.org' }, + ], +}; + +// The busy periods each principal has on the server. +let busy: Record> = {}; +let refuse: Record = {}; + +function answerAvailability(calls: Call[]) { + return { + methodResponses: calls.map(([method, args, id]) => { + if (method !== 'Principal/getAvailability') return ['error', { type: 'unknownMethod' }, id]; + const pid = args.id as string; + if (refuse[pid]) return ['error', { type: refuse[pid] }, id]; + return ['Principal/getAvailability', { list: busy[pid] ?? [] }, id]; + }), + }; +} + +const start = new Date('2027-03-10T09:00:00Z'); +const end = new Date('2027-03-10T10:00:00Z'); + +beforeEach(() => { + resetAvailabilityCaches(); + mockRequest.mockReset(); + mockRequest.mockImplementation(async (calls: Call[]) => answerAvailability(calls)); + mockHasCapability.mockImplementation(() => true); + mockMaxCalls.mockReturnValue(16); + mockListPrincipals.mockReset(); + mockListPrincipals.mockResolvedValue(DIRECTORY); + busy = {}; + refuse = {}; +}); + +describe('free/busy statuses of the invited attendees', () => { + it('tells busy, tentative, free, no busy times and outside users apart', async () => { + busy = { + a: [{ utcStart: '2027-03-10T09:30:00Z', utcEnd: '2027-03-10T11:00:00Z', busyStatus: 'confirmed' }], + b: [{ utcStart: '2027-03-10T08:00:00Z', utcEnd: '2027-03-10T09:15:00Z', busyStatus: 'tentative' }], + // Busy only after the event: free for its window. + c: [{ utcStart: '2027-03-10T10:00:00Z', utcEnd: '2027-03-10T11:00:00Z' }], + }; + const statuses = await lookUpAvailabilityStatuses( + ['alice@example.org', 'bob@example.org', 'carol@example.org', 'dave@elsewhere.net'], + start, + end, + ); + expect(statuses).toEqual({ + 'alice@example.org': 'busy', + 'bob@example.org': 'tentative', + 'carol@example.org': 'free', + 'dave@elsewhere.net': 'unknown', + }); + }); + + it('says no busy times rather than available for an empty answer', async () => { + const statuses = await lookUpAvailabilityStatuses(['alice@example.org'], start, end); + expect(statuses).toEqual({ 'alice@example.org': 'no_busy_times' }); + }); + + it('asks for the whole local days, without event details, and in one request', async () => { + await lookUpAvailabilityStatuses(['alice@example.org', 'bob@example.org'], start, end); + expect(mockRequest).toHaveBeenCalledTimes(1); + const [calls, using] = mockRequest.mock.calls[0] as [Call[], string[]]; + expect(using).toContain('urn:ietf:params:jmap:principals:availability'); + expect(calls.map((c) => c[1].id)).toEqual(['a', 'b']); + for (const [, args] of calls) { + expect(args.showDetails).toBe(false); + expect(args.utcStart).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); + expect(new Date(args.utcStart as string).getTime()).toBeLessThanOrEqual(start.getTime()); + expect(new Date(args.utcEnd as string).getTime()).toBeGreaterThanOrEqual(end.getTime()); + } + }); + + it('answers a move within the same day from the cache', async () => { + await lookUpAvailabilityStatuses(['alice@example.org'], start, end); + await lookUpAvailabilityStatuses(['alice@example.org'], new Date('2027-03-10T13:00:00Z'), new Date('2027-03-10T14:00:00Z')); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('splits the lookups across requests by maxCallsInRequest', async () => { + mockMaxCalls.mockReturnValue(2); + const result = await getPrincipalsAvailability(['a', 'b', 'c'], start, end); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(Object.keys(result)).toEqual(['a', 'b', 'c']); + }); + + it('stops asking for a minute after the server rate-limits', async () => { + refuse = { a: 'rateLimit' }; + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' }); + refuse = {}; + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' }); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('gives up on free/busy when Stalwart 1.0 has directory queries off', async () => { + mockListPrincipals.mockResolvedValue({ status: 'restricted', directoryQueriesOff: true, principals: [] }); + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toBeNull(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('gives up on free/busy when a lookup is refused as forbidden', async () => { + refuse = { a: 'forbidden' }; + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toBeNull(); + }); + + it('cannot call an address unknown when the directory listing was incomplete', async () => { + mockListPrincipals.mockResolvedValue({ status: 'error', principals: DIRECTORY.principals.slice(0, 1) }); + expect(await lookUpAvailabilityStatuses(['zed@example.org'], start, end)).toEqual({ 'zed@example.org': 'error' }); + }); + + it('counts a failed request as unknown, never as available', async () => { + mockRequest.mockRejectedValue(new Error('offline')); + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' }); + }); +}); + +describe('whether free/busy is offered', () => { + it('needs the principals:availability capability', () => { + mockHasCapability.mockImplementation((urn: string) => urn !== 'urn:ietf:params:jmap:principals:availability'); + expect(supportsAvailability()).toBe(false); + }); + + it('reads mayGetAvailability from the user\'s own principal (1.0)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Principal/get', { + list: [{ id: 'u', capabilities: { 'urn:ietf:params:jmap:calendars': { mayGetAvailability: false } } }], + }, '0']], + }); + expect(await mayGetAvailability()).toBe(false); + }); + + it('goes ahead on an empty calendars capability (0.16)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Principal/get', { list: [{ id: 'u', capabilities: { 'urn:ietf:params:jmap:calendars': {} } }] }, '0']], + }); + expect(await mayGetAvailability()).toBe(true); + }); + + it('asks once per session', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Principal/get', { list: [{ id: 'u', capabilities: {} }] }, '0']], + }); + await Promise.all([checkAvailabilityAccess(), checkAvailabilityAccess()]); + await checkAvailabilityAccess(); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('the event window of the editor', () => { + it('spans the whole days of an all-day event, its inclusive last day included', () => { + const window = editorAvailabilityWindow({ + allDay: true, + start: new Date(2027, 2, 10, 15, 0), + end: new Date(2027, 2, 11, 15, 0), + })!; + expect(window.end.getTime() - window.start.getTime()).toBe(2 * 86_400_000 - 1000); + }); + + it('has no window while the end is before the start', () => { + expect(editorAvailabilityWindow({ allDay: false, start: new Date(2027, 2, 10, 10), end: new Date(2027, 2, 10, 9) })).toBeNull(); + }); + + it('lets an unavailable overlap win over a tentative one', () => { + expect(availabilityFor([ + { utcStart: '2027-03-10T09:00:00Z', utcEnd: '2027-03-10T09:30:00Z', busyStatus: 'tentative' }, + { utcStart: '2027-03-10T09:30:00Z', utcEnd: '2027-03-10T10:00:00Z', busyStatus: 'unavailable' }, + ], start, end)).toBe('busy'); + }); +}); diff --git a/src/api/__tests__/live-stalwart-free-busy.test.ts b/src/api/__tests__/live-stalwart-free-busy.test.ts new file mode 100644 index 00000000..746e9e0d --- /dev/null +++ b/src/api/__tests__/live-stalwart-free-busy.test.ts @@ -0,0 +1,157 @@ +/** + * Live free/busy checks (Principal/getAvailability) against a real Stalwart. + * + * Opt-in like live-stalwart-calendar.test.ts: skipped unless + * `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same + * password) are set. The second account gets a busy event in 2027, which the + * first one looks up the way the event editor does; the event carries the + * run's marker and is removed afterwards. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \ + * BULWARK_LIVE_USER=e2e-perf@example.org \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-free-busy.test.ts + * + * Verified against Stalwart 0.16.27 and 1.0.1. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { + checkAvailabilityAccess, + loadPrincipalDirectory, + lookUpAvailabilityStatuses, + resetAvailabilityCaches, + supportsAvailability, +} from '../calendar-availability'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip; + +const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; +const RUN = `live-fb-${Date.now().toString(36)}`; + +type Response = [string, Record, string]; + +async function raw(c: JMAPClient, calls: [string, Record, string][]): Promise { + return (await c.request(calls, USING)).methodResponses as Response[]; +} + +/** Stalwart 1.0 advertises JMAP Metadata; 0.16 does not. */ +function isStalwart10(c: JMAPClient): boolean { + return Object.values(c.currentSession?.accounts ?? {}).some( + (account) => 'urn:ietf:params:jmap:metadata' in (account.accountCapabilities ?? {}), + ); +} + +liveTwo('live Stalwart free/busy', () => { + let other: JMAPClient; + let client: JMAPClient; + const busyIds: string[] = []; + let sharedCalendarId = ''; + + async function addBusy(calendarId: string, start: string): Promise { + const created = await raw(other, [['CalendarEvent/set', { + accountId: other.accountId, + create: { + busy: { + '@type': 'Event', + title: `${RUN} busy`, + calendarIds: { [calendarId]: true }, + start, + timeZone: 'UTC', + duration: 'PT1H', + freeBusyStatus: 'busy', + }, + }, + }, '0']]); + const id = created[0][1].created?.busy?.id; + expect(id, JSON.stringify(created[0][1].notCreated)).toBeTruthy(); + busyIds.push(id); + } + + beforeAll(async () => { + const mod = await import('../jmap-client'); + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); + resetAvailabilityCaches(); + + other = new JMAPClient(); + await other.connect(SERVER!, USER2!, PASS); + const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]); + const list = cals[0][1].list as { id: string; isDefault?: boolean }[]; + // A busy hour in the default calendar, which shares nothing. + await addBusy((list.find((c) => c.isDefault) ?? list[0]).id, '2027-04-14T10:00:00'); + if (!isStalwart10(client)) { + // And one in a calendar of its own that lets the first account see + // free/busy, which is what 0.16 asks before it answers. + const found = await other.request([['Principal/query', { accountId: other.accountId, filter: { email: USER } }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS]); + const me = (found.methodResponses[0][1].ids as string[])[0]; + const made = await raw(other, [['Calendar/set', { + accountId: other.accountId, + create: { fb: { name: `${RUN} free/busy`, shareWith: { [me]: { mayReadFreeBusy: true } } } }, + }, '0']]); + sharedCalendarId = made[0][1].created?.fb?.id ?? ''; + expect(sharedCalendarId, JSON.stringify(made[0][1].notCreated)).toBeTruthy(); + await addBusy(sharedCalendarId, '2027-04-15T10:00:00'); + } + }, 60_000); + + afterAll(async () => { + if (busyIds.length > 0) { + await raw(other, [['CalendarEvent/set', { accountId: other.accountId, destroy: busyIds }, '0']]).catch(() => undefined); + } + if (sharedCalendarId) { + await raw(other, [['Calendar/set', { accountId: other.accountId, destroy: [sharedCalendarId], onDestroyRemoveEvents: true }, '0']]) + .catch(() => undefined); + } + }, 60_000); + + it('is advertised by the server', () => { + expect(supportsAvailability()).toBe(true); + }); + + it('says free/busy is not available on 1.0 with directory queries off, without looking anyone up', async () => { + if (!isStalwart10(client)) return; + // 1.0 keeps directory queries off by default: no address can be + // matched to a principal, and the editor says so in one line. + const directory = await loadPrincipalDirectory(); + expect(directory.status).toBe('restricted'); + expect(directory.directoryQueriesOff).toBe(true); + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase()], + new Date('2027-04-14T10:30:00Z'), + new Date('2027-04-14T11:30:00Z'), + )).toBeNull(); + }); + + it('shows no busy times for someone whose calendars share no free/busy (0.16)', async () => { + if (isStalwart10(client)) return; + expect(await checkAvailabilityAccess()).toBe(true); + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase()], + new Date('2027-04-14T10:30:00Z'), + new Date('2027-04-14T11:30:00Z'), + )).toEqual({ [USER2!.toLowerCase()]: 'no_busy_times' }); + }); + + it('shows the attendee busy during a shared busy hour and free next to it (0.16)', async () => { + if (isStalwart10(client)) return; + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase()], + new Date('2027-04-15T10:30:00Z'), + new Date('2027-04-15T11:30:00Z'), + )).toEqual({ [USER2!.toLowerCase()]: 'busy' }); + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase(), 'nobody@elsewhere.invalid'], + new Date('2027-04-15T12:00:00Z'), + new Date('2027-04-15T13:00:00Z'), + )).toEqual({ [USER2!.toLowerCase()]: 'free', 'nobody@elsewhere.invalid': 'unknown' }); + }); +}); diff --git a/src/api/calendar-availability.ts b/src/api/calendar-availability.ts new file mode 100644 index 00000000..d6f5f5bc --- /dev/null +++ b/src/api/calendar-availability.ts @@ -0,0 +1,322 @@ +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import type { Principal } from './types'; +import { batched } from './jmap-result'; +import { listPrincipals, type PrincipalListing } from './files'; +import { + availabilityWindow, + statusFromAnswer, + type AvailabilityStatus, + type BusyPeriod, + type PrincipalAvailability, +} from '../lib/calendar-free-busy'; + +// Free/busy (Principal/getAvailability) lookups for the event editor. Ported +// from the webmail's lib/jmap/free-busy.ts, lib/jmap/principal-directory.ts, +// the availability methods of lib/jmap/client.ts and lookUpStatuses in +// components/calendar/participant-availability.tsx. +// +// Whether the server answers free/busy for this user at all is asked once +// per session. Stalwart 1.0 says so on the user's own principal +// (`mayGetAvailability`), and refuses to list its directory while the +// administrator keeps directory queries off (its default): without the +// directory no address can be matched to a principal, so the editor says +// free/busy isn't available. Busy periods are cached per principal and window +// for a few minutes: the editor looks everyone up again whenever the time or +// the attendees change, and Stalwart 1.0 allows 100 getAvailability calls per +// account a minute, counting every call however they are batched. + +export const PRINCIPALS_AVAILABILITY_CAPABILITY = 'urn:ietf:params:jmap:principals:availability'; +export const AVAILABILITY_TTL_MS = 3 * 60 * 1000; +/** + * Stalwart counts availability calls in fixed one-minute windows and refuses + * every call until the window is over, without saying when that is. A full + * minute after a refusal is always past it. + */ +export const RATE_LIMIT_BACKOFF_MS = 60 * 1000; +export const PRINCIPAL_DIRECTORY_TTL_MS = 5 * 60 * 1000; + +function principalsUsing(): string[] { + return [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS]; +} + +function availabilityUsing(): string[] { + return [...principalsUsing(), PRINCIPALS_AVAILABILITY_CAPABILITY]; +} + +/** The session the caches belong to: another account or server starts over. */ +function sessionKey(): string { + return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`; +} + +/** Whether the server can answer free/busy queries for its principals. */ +export function supportsAvailability(): boolean { + return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS) + && jmapClient.hasCapability(PRINCIPALS_AVAILABILITY_CAPABILITY); +} + +/** + * Whether this user may ask for other people's free/busy, from the user's + * own principal: Stalwart 1.0 sets `capabilities[calendars].mayGetAvailability` + * to false when it won't answer. 0.16 leaves that object empty, which means + * go ahead. A `forbidden` answer (a role without the principal permissions) + * means no as well; other failures throw. + */ +export async function mayGetAvailability(): Promise { + if (!supportsAvailability()) return false; + const accountId = jmapClient.accountId; + const principals = jmapClient.getAccountCapability(CAPABILITIES.PRINCIPALS, accountId) as + { currentUserPrincipalId?: string | null } | undefined; + const ownId = principals?.currentUserPrincipalId || accountId; + const response = await jmapClient.request( + [['Principal/get', { accountId, ids: [ownId], properties: ['id', 'capabilities'] }, '0']], + principalsUsing(), + ); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== 'Principal/get') { + if (result?.type === 'forbidden') return false; + throw new Error(result?.description || "Failed to read the user's principal"); + } + const own = ((result?.list ?? []) as Array<{ id: string; capabilities?: Record }>) + .find((p) => p.id === ownId); + const calendars = own?.capabilities?.[CAPABILITIES.CALENDARS] as { mayGetAvailability?: boolean } | undefined; + return calendars?.mayGetAvailability !== false; +} + +/** The busy periods of a Principal/getAvailability response. */ +function busyPeriodsOf(result: { list?: unknown } | undefined): BusyPeriod[] { + const list = (result?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: BusyPeriod['busyStatus'] }>; + return list.map((p) => ({ utcStart: p.utcStart, utcEnd: p.utcEnd, busyStatus: p.busyStatus ?? null })); +} + +function availabilityArgs(principalId: string, utcStart: Date, utcEnd: Date): Record { + return { + accountId: jmapClient.accountId, + id: principalId, + utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'), + utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'), + // Only whether someone is free, never what they are doing. + showDetails: false, + }; +} + +/** + * The busy periods of several principals over one window, batched into as + * few requests as maxCallsInRequest allows: Principal/getAvailability takes + * a single id. Each principal gets its periods, or the error type its call + * was refused with. A failed request throws. + */ +export async function getPrincipalsAvailability( + principalIds: string[], + utcStart: Date, + utcEnd: Date, +): Promise> { + const results: Record = {}; + if (!supportsAvailability()) return results; + for (const batch of batched(principalIds, jmapClient.getMaxCallsInRequest())) { + const response = await jmapClient.request( + batch.map((id, i): [string, Record, string] => [ + 'Principal/getAvailability', availabilityArgs(id, utcStart, utcEnd), String(i), + ]), + availabilityUsing(), + ); + const byCallId = new Map(((response.methodResponses ?? []) as [string, any, string][]).map((r) => [r[2], r])); + batch.forEach((id, i) => { + const [method, result] = byCallId.get(String(i)) ?? []; + results[id] = method === 'Principal/getAvailability' + ? { periods: busyPeriodsOf(result) } + : { error: (result as { type?: string } | undefined)?.type || 'serverFail' }; + }); + } + return results; +} + +// ── Access check, once per session ─────────────────────────────── + +const access = new Map(); +const pendingChecks = new Map>(); + +/** What an earlier check learned, or undefined when nothing has been asked yet. */ +export function knownAvailabilityAccess(): boolean | undefined { + return access.get(sessionKey()); +} + +/** Asks the server once per session whether free/busy lookups are allowed. Never rejects. */ +export function checkAvailabilityAccess(): Promise { + const key = sessionKey(); + const known = access.get(key); + if (known !== undefined) return Promise.resolve(known); + let check = pendingChecks.get(key); + if (!check) { + check = mayGetAvailability() + .then((allowed) => { + access.set(key, allowed); + return allowed; + }) + // A failed check proves nothing either way: look up this time and ask + // again next time. + .catch(() => true) + .finally(() => pendingChecks.delete(key)); + pendingChecks.set(key, check); + } + return check; +} + +/** The server refused a lookup outright; stop asking in this session. */ +export function revokeAvailabilityAccess(): void { + access.set(sessionKey(), false); +} + +// ── Principal directory, shared while fresh ────────────────────── + +const directories = new Map }>(); + +/** + * The server's principal listing, shared while it is fresh; callers that + * arrive while it loads get the same request. Never rejects. + */ +export function loadPrincipalDirectory(): Promise { + const key = sessionKey(); + const cached = directories.get(key); + if (cached && Date.now() - cached.loadedAt < PRINCIPAL_DIRECTORY_TTL_MS) return cached.listing; + const listing = listPrincipals().catch((): PrincipalListing => ({ principals: [], status: 'error' })); + const entry = { loadedAt: Date.now(), listing }; + directories.set(key, entry); + void listing.then(({ status }) => { + // A failed listing isn't kept, so the next caller asks again. + if (status === 'error' && directories.get(key) === entry) directories.delete(key); + }); + return listing; +} + +// ── Cached lookups ─────────────────────────────────────────────── + +interface CachedLookup { + requestedAt: number; + result: Promise; +} + +interface LookupState { + cache: Map; + backoffUntil: number; +} + +const lookups = new Map(); + +function lookupState(): LookupState { + const key = sessionKey(); + let state = lookups.get(key); + if (!state) { + state = { cache: new Map(), backoffUntil: 0 }; + lookups.set(key, state); + } + return state; +} + +/** + * The busy periods of each principal over the local days from `start` to + * `end`, for the caller to match against the exact event window. Fresh + * answers come from the cache, lookups already on their way are shared, and + * the rest go out as one batched request. Never rejects: a failed request + * counts as `serverUnavailable`, and while the server is rate limiting, the + * answer is `rateLimit` without asking. + */ +export async function lookUpAvailability( + principalIds: string[], + start: Date, + end: Date, +): Promise> { + const state = lookupState(); + const { cache } = state; + const now = Date.now(); + for (const [key, entry] of cache) { + if (now - entry.requestedAt >= AVAILABILITY_TTL_MS) cache.delete(key); + } + + const days = availabilityWindow(start, end); + const keyOf = (id: string) => `${id}|${days.start.getTime()}|${days.end.getTime()}`; + const results = new Map>(); + const missing: string[] = []; + for (const id of new Set(principalIds)) { + const cached = cache.get(keyOf(id)); + if (cached) results.set(id, cached.result); + else missing.push(id); + } + + if (missing.length > 0 && now < state.backoffUntil) { + for (const id of missing) results.set(id, Promise.resolve({ error: 'rateLimit' })); + } else if (missing.length > 0) { + const batch = getPrincipalsAvailability(missing, days.start, days.end) + .catch((): Record => ({})); + for (const id of missing) { + const key = keyOf(id); + const entry: CachedLookup = { + requestedAt: now, + result: batch.then((answers) => answers[id] ?? { error: 'serverUnavailable' }), + }; + cache.set(key, entry); + results.set(id, entry.result); + void entry.result.then((result) => { + if (!('error' in result)) return; + // Only answers are kept: a refused lookup is asked again next time. + if (cache.get(key) === entry) cache.delete(key); + if (result.error === 'rateLimit') { + state.backoffUntil = Math.max(state.backoffUntil, Date.now() + RATE_LIMIT_BACKOFF_MS); + } + }); + } + } + + const settled = await Promise.all([...results].map(async ([id, result]) => [id, await result] as const)); + return Object.fromEntries(settled); +} + +/** + * The status of every address for the window, or null when the server won't + * answer free/busy for this user: Stalwart 1.0 with directory queries off, + * or a lookup refused with `forbidden`. + */ +export async function lookUpAvailabilityStatuses( + emails: string[], + start: Date, + end: Date, +): Promise | null> { + const directory = await loadPrincipalDirectory(); + // With directory queries off, Stalwart 1.0 can't match an address to a + // principal. Any other refused listing is a role without the permission + // to list principals, which is not the one for free/busy: the principals + // Principal/get still returned are looked up, and a lookup refused in turn + // says it. + if (directory.status === 'restricted' && directory.directoryQueriesOff) return null; + const byEmail = new Map(); + for (const p of directory.principals) { + if (p.email) byEmail.set(p.email.toLowerCase(), p); + } + const principalIds = emails.flatMap((email) => byEmail.get(email)?.id ?? []); + const answers = await lookUpAvailability(principalIds, start, end); + const statuses: Record = {}; + for (const email of emails) { + const principal = byEmail.get(email); + if (!principal) { + // Only a complete listing shows that an address is not a user here. + statuses[email] = directory.status === 'ok' ? 'unknown' : 'error'; + continue; + } + const answer = answers[principal.id]; + if ('error' in answer) { + if (answer.error === 'forbidden') return null; + statuses[email] = 'error'; + } else { + statuses[email] = statusFromAnswer(answer.periods, start, end); + } + } + return statuses; +} + +/** Forget every cached answer (tests, sign-out). */ +export function resetAvailabilityCaches(): void { + access.clear(); + pendingChecks.clear(); + directories.clear(); + lookups.clear(); +} diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 316596bc..15587b71 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -73,6 +73,8 @@ import { import type { SeriesEdit } from '../../lib/recurrence-scope-edit'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; +import { ParticipantAvailability } from './ParticipantAvailability'; +import { editorAvailabilityWindow } from '../../lib/calendar-free-busy'; import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { RecurrenceEditor } from './RecurrenceEditor'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; @@ -257,6 +259,12 @@ export function EventModal({ const [showEndTime, setShowEndTime] = React.useState(false); const [saving, setSaving] = React.useState(false); + // The event window the attendees' free/busy is checked against. + const availabilityWindow = React.useMemo( + () => editorAvailabilityWindow({ allDay, start, end }), + [allDay, start, end], + ); + // What the form holds against what it opened with: Close and Back ask // before they drop changes (webmail 13f9f27c4). const formSnapshot = React.useMemo(() => editorFormSnapshot({ @@ -901,6 +909,7 @@ export function EventModal({ )} + {organizes && attendees.length > 0 && ( diff --git a/src/components/calendar/ParticipantAvailability.tsx b/src/components/calendar/ParticipantAvailability.tsx new file mode 100644 index 00000000..61113f70 --- /dev/null +++ b/src/components/calendar/ParticipantAvailability.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useAuthStore } from '../../stores/auth-store'; +import type { Attendee } from '../../lib/calendar-participants'; +import { attendeeEmails, type AvailabilityStatus } from '../../lib/calendar-free-busy'; +import { + checkAvailabilityAccess, + knownAvailabilityAccess, + lookUpAvailabilityStatuses, + revokeAvailabilityAccess, + supportsAvailability, +} from '../../api/calendar-availability'; + +interface ParticipantAvailabilityProps { + attendees: Attendee[]; + /** The event window as real instants; null while the form has no valid dates. */ + window: { start: Date; end: Date } | null; +} + +const STATUS_FALLBACKS: Record = { + free: 'Available', + busy: 'Busy', + tentative: 'Tentative', + no_busy_times: 'No busy times shared', + unknown: 'Not a user on this server', + error: "Couldn't check", + checking: 'Checking…', +}; + +/** + * Free/busy of the invited attendees for the event window + * (Principal/getAvailability), as the webmail's + * components/calendar/participant-availability.tsx shows it. Only attendees + * that are principals on this server can be checked; external addresses show + * as unknown. When the server won't answer free/busy for this user, one line + * says so; nothing is shown when it doesn't offer free/busy at all. + */ +export function ParticipantAvailability({ attendees, window }: ParticipantAvailabilityProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + // Another account or server asks its own question. + const session = useAuthStore((s) => s.session); + const supported = !!session && supportsAvailability(); + const [statuses, setStatuses] = React.useState>({}); + const [access, setAccess] = React.useState<{ session: unknown; allowed: boolean } | null>(null); + const requestSeq = React.useRef(0); + + const emails = React.useMemo(() => attendeeEmails(attendees), [attendees]); + const emailsKey = emails.join(','); + const startMs = window?.start.getTime() ?? null; + const endMs = window?.end.getTime() ?? null; + const wanted = supported && emails.length > 0 && startMs !== null && endMs !== null && endMs > startMs; + // Undefined until the server has said whether it answers free/busy for + // this user; the block stays hidden until then, so a server that refuses + // never flashes a list of attendees being checked. + const allowed = !session ? undefined : access?.session === session ? access.allowed : knownAvailabilityAccess(); + + React.useEffect(() => { + if (!wanted || allowed !== undefined) return; + let cancelled = false; + void checkAvailabilityAccess().then((ok) => { + if (!cancelled) setAccess({ session, allowed: ok }); + }); + return () => { + cancelled = true; + }; + }, [session, wanted, allowed]); + + React.useEffect(() => { + if (!wanted || allowed !== true) { + setStatuses({}); + return; + } + const seq = ++requestSeq.current; + setStatuses(Object.fromEntries(emails.map((e) => [e, 'checking' as const]))); + // Debounce: changing a time or adding several people must not fire a + // query per change. + const timer = setTimeout(async () => { + const results = await lookUpAvailabilityStatuses(emails, new Date(startMs!), new Date(endMs!)); + if (seq !== requestSeq.current) return; + if (results === null) { + revokeAvailabilityAccess(); + setAccess({ session, allowed: false }); + return; + } + setStatuses(results); + }, 400); + return () => clearTimeout(timer); + // emailsKey stands in for the emails array identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session, wanted, allowed, emailsKey, startMs, endMs]); + + if (!wanted) return null; + if (allowed === false) { + return ( + + {t('calendar.participants.availability.unavailable_on_server', "Free/busy isn't available on this server")} + + ); + } + if (allowed !== true) return null; + + return ( + + {attendees.map((a) => { + const status = statuses[a.email.trim().toLowerCase()] ?? 'checking'; + const label = t(`calendar.participants.availability.${status}`, STATUS_FALLBACKS[status]); + return ( + + + {a.name || a.email} + + {status === 'checking' && } + {label} + + + ); + })} + + ); +} + +export function dotColor(status: AvailabilityStatus, c: ThemePalette): string { + if (status === 'free') return c.success; + if (status === 'busy') return c.error; + if (status === 'tentative') return c.warning; + return c.border; +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + unavailable: { + ...typography.caption, + color: c.textMuted, + marginTop: spacing.xs, + }, + list: { + marginTop: spacing.xs, + gap: 2, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 20, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, + name: { + ...typography.caption, + color: c.text, + flexShrink: 1, + }, + statusWrap: { + marginStart: 'auto', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + spinner: { + transform: [{ scale: 0.6 }], + }, + status: { + ...typography.caption, + color: c.textMuted, + }, + }); +} diff --git a/src/components/calendar/__tests__/participant-availability-wiring.test.ts b/src/components/calendar/__tests__/participant-availability-wiring.test.ts new file mode 100644 index 00000000..8cb3bb66 --- /dev/null +++ b/src/components/calendar/__tests__/participant-availability-wiring.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Checked on the source: the suite has no React Native renderer. The lookups +// themselves are covered in src/api/__tests__/calendar-availability.test.ts. +const modal = fs.readFileSync(path.resolve(__dirname, '../EventModal.tsx'), 'utf8'); + +describe('free/busy in the event editor', () => { + it('shows the attendees’ availability under the participants for the event’s window', () => { + expect(modal).toMatch(//); + expect(modal).toMatch(/editorAvailabilityWindow\(\{ allDay, start, end \}\)/); + // Right after the participant list, as in the webmail's event form. + const input = modal.indexOf(' start.getTime()) { + if (p.busyStatus === 'tentative') { + if (status === 'free') status = 'tentative'; + } else { + return 'busy'; + } + } + } + return status; +} + +/** The answer for one attendee: an empty list doesn't mean available. */ +export function statusFromAnswer(periods: BusyPeriod[], start: Date, end: Date): AvailabilityStatus { + return periods.length === 0 ? 'no_busy_times' : availabilityFor(periods, start, end); +} + +/** + * The window a lookup asks the server for: the local days from `start` to + * `end`, so that moving an event within its day is answered from the cache. + */ +export function availabilityWindow(start: Date, end: Date): { start: Date; end: Date } { + const from = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + const to = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + if (to.getTime() < end.getTime()) to.setDate(to.getDate() + 1); + return { start: from, end: to }; +} + +/** + * The event window the attendees' free/busy is checked against, as real + * instants, from the editor's display dates (wall clocks in the calendar's + * time zone). An all-day event spans its whole days, `end` being its + * inclusive last day. Null while the window is empty or backwards. + */ +export function editorAvailabilityWindow( + times: { allDay: boolean; start: Date; end: Date }, +): { start: Date; end: Date } | null { + let start = times.start; + let end = times.end; + if (times.allDay) { + start = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0); + end = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 23, 59, 59); + } + const from = fromDisplayDate(start); + const to = fromDisplayDate(end); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to.getTime() <= from.getTime()) return null; + return { start: from, end: to }; +} + +/** The distinct, trimmed, lower-cased addresses of the attendees. */ +export function attendeeEmails(attendees: { email: string }[]): string[] { + return Array.from(new Set(attendees.map((a) => a.email.trim().toLowerCase()).filter(Boolean))); +}