feat: show invited participants' free/busy in the event editor

This commit is contained in:
Linus Rath
2026-10-31 14:22:59 +01:00
parent 5ecf6f3256
commit e6a89ad07a
7 changed files with 992 additions and 0 deletions
@@ -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, unknown>, string];
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
const mockListPrincipals = listPrincipals as ReturnType<typeof vi.fn>;
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<string, Array<{ utcStart: string; utcEnd: string; busyStatus?: string }>> = {};
let refuse: Record<string, string> = {};
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');
});
});
@@ -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, any>, string];
async function raw(c: JMAPClient, calls: [string, Record<string, unknown>, string][]): Promise<Response[]> {
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<void> {
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' });
});
});
+322
View File
@@ -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<boolean> {
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<string, unknown> }>)
.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<string, unknown> {
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<Record<string, PrincipalAvailability>> {
const results: Record<string, PrincipalAvailability> = {};
if (!supportsAvailability()) return results;
for (const batch of batched(principalIds, jmapClient.getMaxCallsInRequest())) {
const response = await jmapClient.request(
batch.map((id, i): [string, Record<string, unknown>, 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<string, boolean>();
const pendingChecks = new Map<string, Promise<boolean>>();
/** 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<boolean> {
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<string, { loadedAt: number; listing: Promise<PrincipalListing> }>();
/**
* 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<PrincipalListing> {
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<PrincipalAvailability>;
}
interface LookupState {
cache: Map<string, CachedLookup>;
backoffUntil: number;
}
const lookups = new Map<string, LookupState>();
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<Record<string, PrincipalAvailability>> {
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<string, Promise<PrincipalAvailability>>();
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<string, PrincipalAvailability> => ({}));
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<Record<string, AvailabilityStatus> | 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<string, Principal>();
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<string, AvailabilityStatus> = {};
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();
}
+9
View File
@@ -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({
</Text>
</View>
)}
<ParticipantAvailability attendees={attendees} window={availabilityWindow} />
{organizes && attendees.length > 0 && (
<View style={styles.allDayRow}>
<Text style={[styles.fieldText, { flex: 1 }]}>
@@ -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<AvailabilityStatus, string> = {
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<Record<string, AvailabilityStatus>>({});
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 (
<Text style={styles.unavailable} accessibilityRole="text" accessibilityLiveRegion="polite">
{t('calendar.participants.availability.unavailable_on_server', "Free/busy isn't available on this server")}
</Text>
);
}
if (allowed !== true) return null;
return (
<View
style={styles.list}
accessibilityLabel={t('calendar.participants.availability.title', 'Availability')}
>
{attendees.map((a) => {
const status = statuses[a.email.trim().toLowerCase()] ?? 'checking';
const label = t(`calendar.participants.availability.${status}`, STATUS_FALLBACKS[status]);
return (
<View key={a.email} style={styles.row} accessible accessibilityLabel={`${a.name || a.email}: ${label}`}>
<View style={[styles.dot, { backgroundColor: dotColor(status, c) }]} />
<Text style={styles.name} numberOfLines={1}>{a.name || a.email}</Text>
<View style={styles.statusWrap}>
{status === 'checking' && <ActivityIndicator size="small" color={c.textMuted} style={styles.spinner} />}
<Text style={styles.status}>{label}</Text>
</View>
</View>
);
})}
</View>
);
}
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,
},
});
}
@@ -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(/<ParticipantAvailability attendees=\{attendees\} window=\{availabilityWindow\} \/>/);
expect(modal).toMatch(/editorAvailabilityWindow\(\{ allDay, start, end \}\)/);
// Right after the participant list, as in the webmail's event form.
const input = modal.indexOf('<ParticipantInput');
const availability = modal.indexOf('<ParticipantAvailability');
const invitations = modal.indexOf("'calendar.participants.send_invitations'");
expect(input).toBeGreaterThan(-1);
expect(availability).toBeGreaterThan(input);
expect(availability).toBeLessThan(invitations);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { fromDisplayDate } from './calendar-timezone';
// Free/busy of invited attendees (Principal/getAvailability), the parts
// that need no server. Ported from the webmail's
// components/calendar/participant-availability.tsx and lib/jmap/free-busy.ts.
/** One busy period of a Principal/getAvailability answer. */
export interface BusyPeriod {
utcStart: string;
utcEnd: string;
busyStatus: 'confirmed' | 'tentative' | 'unavailable' | null;
}
/** A principal's busy periods, or the error type its lookup was refused with. */
export type PrincipalAvailability = { periods: BusyPeriod[] } | { error: string };
/**
* `unknown`: the address is not a user in a complete directory listing.
* `no_busy_times`: the server returned no busy time for the attendee's day.
* It says the same when the user may not see that person's free/busy.
* `error`: the lookup failed or was rate-limited, so nothing is known.
*/
export type AvailabilityStatus =
| 'free'
| 'busy'
| 'tentative'
| 'no_busy_times'
| 'unknown'
| 'error'
| 'checking';
/**
* The status of one attendee for the window, given their busy periods:
* any confirmed/unavailable overlap wins over a tentative one.
*/
export function availabilityFor(periods: BusyPeriod[], start: Date, end: Date): 'free' | 'busy' | 'tentative' {
let status: 'free' | 'busy' | 'tentative' = 'free';
for (const p of periods) {
const pStart = new Date(p.utcStart).getTime();
const pEnd = new Date(p.utcEnd).getTime();
if (pStart < end.getTime() && pEnd > 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)));
}