diff --git a/android/app/src/main/res/raw/involved_notification.mp3 b/android/app/src/main/res/raw/involved_notification.mp3
new file mode 100644
index 00000000..22bd26f8
Binary files /dev/null and b/android/app/src/main/res/raw/involved_notification.mp3 differ
diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml
new file mode 100644
index 00000000..865f0412
--- /dev/null
+++ b/android/app/src/main/res/raw/keep.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app.config.js b/app.config.js
index bf40cb11..ee306356 100644
--- a/app.config.js
+++ b/app.config.js
@@ -106,6 +106,15 @@ module.exports = {
},
],
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
+ // Bundles the Involved sound calendar reminders play on iOS
+ // (src/lib/calendar-notifications.ts). Android has it in res/raw.
+ [
+ 'expo-notifications',
+ {
+ mode: APS_ENVIRONMENT,
+ sounds: ['./assets/sounds/involved_notification.wav'],
+ },
+ ],
],
extra: {
commit: COMMIT,
diff --git a/assets/sounds/involved_notification.wav b/assets/sounds/involved_notification.wav
new file mode 100644
index 00000000..284e743b
Binary files /dev/null and b/assets/sounds/involved_notification.wav differ
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-event-notifications.test.ts b/src/api/__tests__/live-stalwart-event-notifications.test.ts
new file mode 100644
index 00000000..6f35a0df
--- /dev/null
+++ b/src/api/__tests__/live-stalwart-event-notifications.test.ts
@@ -0,0 +1,171 @@
+/**
+ * Live CalendarEventNotification checks 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 invites the first one to an event in
+ * 2027 and then cancels it; the first one reads the notifications the server
+ * keeps for that and acknowledges them. Only the notifications of this run
+ * are destroyed, and the event carries the run's marker and is removed from
+ * both accounts 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-event-notifications.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 {
+ destroyCalendarEventNotifications,
+ getCalendarEventNotifications,
+ type CalendarEventNotification,
+} from '../calendar-event-notifications';
+import { eventNotificationToast } from '../../lib/calendar-event-notifications';
+
+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-cen-${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[];
+}
+
+async function waitFor(read: () => Promise, done: (value: T) => boolean, ms = 15_000): Promise {
+ const until = Date.now() + ms;
+ let value = await read();
+ while (!done(value) && Date.now() < until) {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ value = await read();
+ }
+ return value;
+}
+
+/** Destroy every event of `c`'s account whose title carries the run's marker. */
+async function removeRunEvents(c: JMAPClient): Promise {
+ const res = await raw(c, [
+ ['CalendarEvent/query', { accountId: c.accountId }, 'q'],
+ ['CalendarEvent/get', {
+ accountId: c.accountId,
+ '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
+ properties: ['id', 'title'],
+ }, 'g'],
+ ]);
+ const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[])
+ .filter((e) => e.title?.includes(RUN))
+ .map((e) => e.id);
+ if (ids.length > 0) {
+ await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]);
+ }
+}
+
+const t = (_key: string, fallback?: string, params?: Record) =>
+ (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
+
+liveTwo('live Stalwart calendar event notifications', () => {
+ let other: JMAPClient;
+ let before = new Set();
+ const ours = new Set();
+ let organizerEventId = '';
+ let attendeeEventId = '';
+
+ const fresh = async (): Promise =>
+ (await getCalendarEventNotifications()).filter((n) => !before.has(n.id));
+
+ beforeAll(async () => {
+ const mod = await import('../jmap-client');
+ const client = new JMAPClient();
+ await client.connect(SERVER!, USER, PASS);
+ (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
+ other = new JMAPClient();
+ await other.connect(SERVER!, USER2!, PASS);
+ // Whatever is pending already belongs to someone else's run.
+ before = new Set((await getCalendarEventNotifications()).map((n) => n.id));
+ }, 60_000);
+
+ afterAll(async () => {
+ const leftover = (await getCalendarEventNotifications().catch(() => []))
+ .filter((n) => ours.has(n.id) || (!before.has(n.id) && n.event?.title?.includes(RUN)));
+ await destroyCalendarEventNotifications(leftover.map((n) => n.id)).catch(() => undefined);
+ await removeRunEvents(other).catch(() => undefined);
+ const mod = await import('../jmap-client');
+ await removeRunEvents(mod.jmapClient as unknown as JMAPClient).catch(() => undefined);
+ }, 60_000);
+
+ it('lists the invitation another account sent, with who sent it and the event’s title', async () => {
+ 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 }[];
+ const calendarId = (list.find((c) => c.isDefault) ?? list[0]).id;
+ const created = await raw(other, [['CalendarEvent/set', {
+ accountId: other.accountId,
+ create: {
+ inv: {
+ '@type': 'Event',
+ title: `${RUN} review`,
+ calendarIds: { [calendarId]: true },
+ start: '2027-06-08T10:00:00',
+ timeZone: 'Europe/Berlin',
+ duration: 'PT1H',
+ organizerCalendarAddress: `mailto:${USER2}`,
+ participants: {
+ org: {
+ '@type': 'Participant', calendarAddress: `mailto:${USER2}`, email: USER2,
+ roles: { owner: true, attendee: true }, participationStatus: 'accepted', kind: 'individual',
+ },
+ att: {
+ '@type': 'Participant', calendarAddress: `mailto:${USER}`, email: USER,
+ roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true, kind: 'individual',
+ },
+ },
+ },
+ },
+ sendSchedulingMessages: true,
+ }, '0']]);
+ organizerEventId = created[0][1].created?.inv?.id ?? '';
+ expect(organizerEventId, JSON.stringify(created[0][1].notCreated)).toBeTruthy();
+
+ const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'created'));
+ const invite = notes.find((n) => n.type === 'created');
+ expect(invite, JSON.stringify(notes)).toBeTruthy();
+ ours.add(invite!.id);
+ attendeeEventId = invite!.calendarEventId;
+ expect(invite!.changedBy?.email?.toLowerCase()).toBe(USER2!.toLowerCase());
+ expect(invite!.isDraft).toBe(false);
+ // 1.0 returns the event with the notification; 0.16 doesn't, and the
+ // title is read from the attendee's copy instead.
+ expect(invite!.event?.title).toBe(`${RUN} review`);
+ expect(eventNotificationToast(invite!, t)!.title).toContain(`invited you to "${RUN} review"`);
+ });
+
+ it('lists the cancellation, and forgets both once acknowledged', async () => {
+ expect(organizerEventId).toBeTruthy();
+ await raw(other, [['CalendarEvent/set', {
+ accountId: other.accountId,
+ destroy: [organizerEventId],
+ sendSchedulingMessages: true,
+ }, '0']]);
+ const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'destroyed' || (n.type === 'updated' && n.calendarEventId === attendeeEventId && !ours.has(n.id))));
+ const cancel = notes.find((n) => n.calendarEventId === attendeeEventId && !ours.has(n.id));
+ expect(cancel, JSON.stringify(notes)).toBeTruthy();
+ ours.add(cancel!.id);
+ const toast = eventNotificationToast(cancel!, t)!;
+ if (cancel!.type === 'destroyed') expect(toast.type).toBe('warning');
+
+ await destroyCalendarEventNotifications([...ours]);
+ const after = (await getCalendarEventNotifications()).map((n) => n.id);
+ for (const id of ours) expect(after).not.toContain(id);
+ // Nobody else's notifications went with them.
+ for (const id of before) expect(after).toContain(id);
+ });
+});
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/api/calendar-event-notifications.ts b/src/api/calendar-event-notifications.ts
new file mode 100644
index 00000000..deaf3ae3
--- /dev/null
+++ b/src/api/calendar-event-notifications.ts
@@ -0,0 +1,94 @@
+import { jmapClient } from './jmap-client';
+import { CAPABILITIES } from './types';
+import { batched } from './jmap-result';
+
+// CalendarEventNotification (draft-ietf-jmap-calendars §7): the server
+// records every invitation, update and cancellation another participant made
+// to an event this user takes part in. Ported from the webmail's
+// getCalendarEventNotifications / destroyCalendarEventNotifications in
+// lib/jmap/client.ts.
+
+export interface CalendarEventNotification {
+ id: string;
+ created: string;
+ changedBy?: {
+ name?: string | null;
+ email?: string | null;
+ principalId?: string | null;
+ } | null;
+ comment?: string | null;
+ type: 'created' | 'updated' | 'destroyed';
+ calendarEventId: string;
+ isDraft?: boolean;
+ /** The event as it is now; 1.0 returns it, 0.16 leaves it out. */
+ event?: { title?: string | null } | null;
+}
+
+const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
+
+/**
+ * Pending event notifications, oldest first. `event` is asked for its title
+ * only; Stalwart 0.16 doesn't return it, so the titles it lacks are read from
+ * the events themselves (a cancelled event is gone and stays untitled).
+ * Throws when the server refuses the listing.
+ */
+export async function getCalendarEventNotifications(): Promise {
+ const accountId = jmapClient.accountId;
+ const response = await jmapClient.request([
+ ['CalendarEventNotification/query', {
+ accountId,
+ sort: [{ property: 'created', isAscending: true }],
+ }, '0'],
+ ['CalendarEventNotification/get', {
+ accountId,
+ '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' },
+ properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event'],
+ }, '1'],
+ ], USING);
+ const responses = (response.methodResponses ?? []) as [string, any, string][];
+ const getResp = responses.find((r) => r[0] === 'CalendarEventNotification/get');
+ if (!getResp) {
+ const error = responses.find((r) => r[0] === 'error')?.[1] as { description?: string } | undefined;
+ throw new Error(error?.description || 'Failed to load calendar event notifications');
+ }
+ const list = (getResp[1]?.list ?? []) as CalendarEventNotification[];
+ await fillMissingTitles(accountId, list);
+ return list;
+}
+
+async function fillMissingTitles(accountId: string, list: CalendarEventNotification[]): Promise {
+ const ids = Array.from(new Set(
+ list.filter((n) => !n.isDraft && n.type !== 'destroyed' && !n.event?.title && n.calendarEventId)
+ .map((n) => n.calendarEventId),
+ ));
+ if (ids.length === 0) return;
+ try {
+ const response = await jmapClient.request(
+ [['CalendarEvent/get', { accountId, ids, properties: ['id', 'title'] }, '0']],
+ USING,
+ );
+ const [method, result] = response.methodResponses?.[0] ?? [];
+ if (method !== 'CalendarEvent/get') return;
+ const titles = new Map();
+ for (const event of (result?.list ?? []) as { id: string; title?: string | null }[]) {
+ if (event.title) titles.set(event.id, event.title);
+ }
+ for (const n of list) {
+ const title = titles.get(n.calendarEventId);
+ if (title && !n.event?.title) n.event = { ...(n.event ?? {}), title };
+ }
+ } catch {
+ // Untitled it is.
+ }
+}
+
+/** Acknowledges (destroys) event notifications that have been shown. */
+export async function destroyCalendarEventNotifications(ids: string[]): Promise {
+ if (ids.length === 0) return;
+ const accountId = jmapClient.accountId;
+ for (const batch of batched(ids, jmapClient.getMaxObjectsInSet())) {
+ await jmapClient.request([
+ ['CalendarEventNotification/set', { accountId, destroy: batch }, '0'],
+ ], USING);
+ }
+}
diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx
index 937c6a10..b5072c7f 100644
--- a/src/components/calendar/AgendaView.tsx
+++ b/src/components/calendar/AgendaView.tsx
@@ -23,6 +23,7 @@ import {
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import { buildAgendaDays, findAgendaFocusIndex, type AgendaDay } from '../../lib/calendar-agenda';
import { useCalendarLocale } from '../../lib/calendar-locale';
+import { dayNumberLabel, dayRangeLabel, fullDateLabel, monthTitle, type CalendarSystem } from '../../lib/calendar-system';
import { useLocaleStore } from '../../stores/locale-store';
import { EventCard } from './EventCard';
@@ -63,12 +64,12 @@ interface DaySection extends AgendaDay {
weekTitle: string | null;
}
-// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months.
-function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string {
+// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months
+// (Jalali days and months in Persian).
+function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale, system: CalendarSystem): string {
const start = startOfWeek(date, { weekStartsOn });
const end = endOfWeek(date, { weekStartsOn });
- const sameMonth = start.getMonth() === end.getMonth();
- return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`;
+ return dayRangeLabel(start, end, system, locale);
}
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 };
@@ -102,7 +103,7 @@ export function AgendaView({
}: AgendaViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
- const { locale, t } = useCalendarLocale();
+ const { locale, t, system } = useCalendarLocale();
const tp = useLocaleStore((s) => s.t);
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
@@ -115,15 +116,15 @@ export function AgendaView({
let lastMonth = '';
let lastWeek = '';
return buildAgendaDays(index, loaded, displayNow()).map((day) => {
- const month = format(day.date, 'yyyy-MM');
+ const month = String(system.monthKey(day.date));
const week = dayKey(startOfWeek(day.date, { weekStartsOn }));
- const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null;
- const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null;
+ const title = month !== lastMonth ? monthTitle(day.date, system, locale, true) : null;
+ const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale, system) : null;
lastMonth = month;
lastWeek = week;
- return { ...day, key: dayKey(day.date), monthTitle, weekTitle };
+ return { ...day, key: dayKey(day.date), monthTitle: title, weekTitle };
});
- }, [index, loaded, weekStartsOn, locale]);
+ }, [index, loaded, weekStartsOn, locale, system]);
const listRef = React.useRef>(null);
const sectionsRef = React.useRef(sections);
@@ -245,7 +246,7 @@ export function AgendaView({
sizeRef.current.content = h;
}, []);
- const formatRangeDate = (date: Date) => format(date, 'PP', { locale });
+ const formatRangeDate = (date: Date) => (system.jalali ? fullDateLabel(date, system, locale) : format(date, 'PP', { locale }));
const header = (
@@ -383,6 +384,7 @@ function DateColumn({
locale: Locale;
styles: AgendaStyles;
}) {
+ const { system } = useCalendarLocale();
if (!show) return ;
const today = isDisplayToday(date);
return (
@@ -392,7 +394,7 @@ function DateColumn({
- {format(date, 'd')}
+ {dayNumberLabel(date, system)}
diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx
index 67671056..c2a7e085 100644
--- a/src/components/calendar/CalendarSearchSheet.tsx
+++ b/src/components/calendar/CalendarSearchSheet.tsx
@@ -17,6 +17,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
+import { formatCalendarDay } from '../../lib/calendar-system';
import { displayNow } from '../../lib/calendar-timezone';
import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search';
import {
@@ -62,7 +63,7 @@ export function CalendarSearchSheet({
}: CalendarSearchSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
- const { locale, t } = useCalendarLocale();
+ const { locale, t, system } = useCalendarLocale();
const loaded = useCalendarStore((s) => s.events);
const [query, setQuery] = React.useState('');
const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null);
@@ -149,7 +150,9 @@ export function CalendarSearchSheet({
>
- {format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })}
+ {system.jalali
+ ? formatCalendarDay(start, locale, system.year(start) === system.year(new Date()) ? 'weekdayMonthDay' : 'monthDayYear')
+ : format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })}
{event.showWithoutTime
diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx
index a03ab3a8..3a52bcc1 100644
--- a/src/components/calendar/EventDetailSheet.tsx
+++ b/src/components/calendar/EventDetailSheet.tsx
@@ -57,7 +57,7 @@ import {
import { getEventEditability } from '../../lib/calendar-editability';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { useCalendarLocale } from '../../lib/calendar-locale';
-import { formatCalendarDate } from '../../lib/date-format';
+import { formatCalendarDay } from '../../lib/calendar-system';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
import type { TranslateFn } from '../../stores/locale-store';
@@ -99,9 +99,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
) {
- return formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear');
+ return formatCalendarDay(start, locale, 'longWeekdayMonthDayYear');
}
- return `${formatCalendarDate(start, locale.code, 'monthDay')} – ${formatCalendarDate(end, locale.code, 'monthDayYear')}`;
+ return `${formatCalendarDay(start, locale, 'monthDay')} – ${formatCalendarDay(end, locale, 'monthDayYear')}`;
}
const tp = timePattern(timeFormat);
const sameDay =
@@ -109,9 +109,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
- return `${formatCalendarDate(start, locale.code, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
+ return `${formatCalendarDay(start, locale, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
}
- return `${formatCalendarDate(start, locale.code, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDate(end, locale.code, 'monthDay')}, ${format(end, tp, opts)}`;
+ return `${formatCalendarDay(start, locale, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDay(end, locale, 'monthDay')}, ${format(end, tp, opts)}`;
}
function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null {
diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx
index 316596bc..7c45d440 100644
--- a/src/components/calendar/EventModal.tsx
+++ b/src/components/calendar/EventModal.tsx
@@ -48,7 +48,7 @@ import { useSettingsStore } from '../../stores/settings-store';
import { CalendarAccountChangedError } from '../../stores/calendar-store';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
-import { formatCalendarDate } from '../../lib/date-format';
+import { formatCalendarDay } from '../../lib/calendar-system';
import {
alertsToReminders,
calendarDefaultAlerts,
@@ -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({
@@ -633,7 +641,7 @@ export function EventModal({
style={styles.dateBtn}
onPress={() => setShowStartDate(true)}
>
- {formatCalendarDate(start, locale.code, 'weekdayMonthDay')}
+ {formatCalendarDay(start, locale, 'weekdayMonthDay')}
{!allDay && (
setShowEndDate(true)}
>
- {formatCalendarDate(end, locale.code, 'weekdayMonthDay')}
+ {formatCalendarDay(end, locale, 'weekdayMonthDay')}
{!allDay && (
)}
+
{organizes && attendees.length > 0 && (
diff --git a/src/components/calendar/MonthDropdown.tsx b/src/components/calendar/MonthDropdown.tsx
index 5d03fad1..4caa1283 100644
--- a/src/components/calendar/MonthDropdown.tsx
+++ b/src/components/calendar/MonthDropdown.tsx
@@ -9,11 +9,11 @@ import {
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
-import { addMonths, startOfMonth } from 'date-fns';
import type { Calendar } from '../../api/types';
import type { ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import type { EventDayIndex } from '../../lib/calendar-utils';
+import { useCalendarLocale } from '../../lib/calendar-locale';
import { MONTH_ROW_HEIGHT, MonthView } from './MonthView';
type WeekStart = 0 | 1 | 6;
@@ -51,6 +51,7 @@ export function MonthDropdown({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
+ const { system } = useCalendarLocale();
// Pages are as wide as the panel (the window, or a pane on a tablet) and
// re-measure on rotation. In a short window the panel takes at most about
// half of it and its month scrolls inside.
@@ -58,9 +59,10 @@ export function MonthDropdown({
const width = measuredWidth || windowWidth;
const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55));
// The months are fixed while the panel is open; it remounts on each open.
+ // Jalali months for Persian, as the month grid shows them.
const [months] = React.useState(() => {
- const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
- return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i));
+ const first = system.addMonths(system.startOfMonth(month), -MONTHS_EACH_SIDE);
+ return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => system.addMonths(first, i));
});
const lastIndexRef = React.useRef(MONTHS_EACH_SIDE);
@@ -129,7 +131,7 @@ export function MonthDropdown({
}
function monthKey(month: Date): string {
- return `${month.getFullYear()}-${month.getMonth()}`;
+ return month.toISOString();
}
function makeStyles(c: ThemePalette) {
diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx
index 5e2a07df..af033c86 100644
--- a/src/components/calendar/MonthScrollView.tsx
+++ b/src/components/calendar/MonthScrollView.tsx
@@ -94,12 +94,12 @@ function MonthScrollViewInner({
onLongPressDate,
}: MonthScrollViewProps) {
const styles = useMonthStyles();
- const { locale } = useCalendarLocale();
+ const { locale, system } = useCalendarLocale();
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
);
- const opts = React.useMemo(() => ({ weekStartsOn }), [weekStartsOn]);
+ const opts = React.useMemo(() => ({ weekStartsOn, calendarSystem: system }), [weekStartsOn, system]);
// One row per week of the window. A week keeps its array across window
// changes, so growing the window doesn't re-render the rows already there.
@@ -131,8 +131,16 @@ function MonthScrollViewInner({
// The month in focus: set by navigation, then by what scrolls under the
// sample line.
- const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date));
+ const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date, system));
const activeMonthRef = React.useRef(activeMonth);
+ // Another calendar system (the language changed) counts months its own way.
+ const systemRef = React.useRef(system);
+ if (systemRef.current !== system) {
+ systemRef.current = system;
+ const key = monthKeyOf(focus.date, system);
+ activeMonthRef.current = key;
+ setActiveMonth(key);
+ }
const onVisibleRef = React.useRef(onVisibleDateChange);
onVisibleRef.current = onVisibleDateChange;
const offsetRef = React.useRef(0);
@@ -163,7 +171,7 @@ function MonthScrollViewInner({
if (!days) return;
// Mid-week decides which month a row belongs to.
const mid = days[3];
- const key = monthKeyOf(mid);
+ const key = monthKeyOf(mid, systemRef.current);
if (key === activeMonthRef.current) return;
activeMonthRef.current = key;
setActiveMonth(key);
@@ -191,14 +199,14 @@ function MonthScrollViewInner({
handledNonceRef.current = focus.nonce;
const row = monthFocusRow(window, focus.date, weeks.length, opts);
const currentRow = Math.round(offsetRef.current / rowHeight);
- const key = monthKeyOf(focus.date);
+ const key = monthKeyOf(focus.date, system);
activeMonthRef.current = key;
setActiveMonth(key);
listRef.current?.scrollToIndex({
index: row,
animated: Math.abs(row - currentRow) <= SMOOTH_SCROLL_ROWS,
});
- }, [focus, window, weeks.length, opts, rowHeight]);
+ }, [focus, window, weeks.length, opts, rowHeight, system]);
const selectedKey = dayKey(selectedDate);
// Today on a clock in the calendar's time zone.
@@ -209,7 +217,7 @@ function MonthScrollViewInner({
return (
s.calendarTintedEvents);
@@ -198,7 +196,7 @@ function MonthWeekRowInner({
const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS;
const visible = dayEvents.slice(0, maxVisible);
const overflow = Math.max(0, dayEvents.length - maxVisible);
- const monthLabel = labelMonths && d.getDate() === 1 ? format(d, 'MMM', { locale }) : null;
+ const monthLabel = labelMonths && isFirstOfMonth(d, system) ? monthStartLabel(d, system, locale, false) : null;
return (
- {format(d, 'd')}
+ {dayNumberLabel(d, system)}
{dayEvents.length > 0 && !showTimeInMonthView && (
@@ -330,7 +328,7 @@ function FullWeekRow({
colors: ThemePalette;
}) {
const { width } = useWindowDimensions();
- const { t } = useCalendarLocale();
+ const { t, system } = useCalendarLocale();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
@@ -345,7 +343,9 @@ function FullWeekRow({
const dayEvents = eventsOnDayFromIndex(index, d);
const shown = fullCellChipCount(height, dayEvents.length);
const overflow = dayEvents.length - shown;
- const label = labelMonths && d.getDate() === 1 ? format(d, 'MMM d', { locale }) : format(d, 'd');
+ const label = labelMonths && isFirstOfMonth(d, system)
+ ? monthStartLabel(d, system, locale, true)
+ : dayNumberLabel(d, system);
return (
eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
);
- // The month grid as rows of 7 days.
- const rows = React.useMemo(() => {
- const calStart = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
- const calEnd = endOfWeek(endOfMonth(currentDate), { weekStartsOn });
- const out: Date[][] = [];
- let day = calStart;
- while (day <= calEnd) {
- out.push(Array.from({ length: 7 }, (_, i) => addDays(day, i)));
- day = addDays(day, 7);
- }
- return out;
- }, [currentDate, weekStartsOn]);
- const activeMonth = monthKeyOf(currentDate);
+ // The month grid as rows of 7 days, in the calendar system's months.
+ const rows = React.useMemo(
+ () => monthGridRows(currentDate, weekStartsOn, system),
+ [currentDate, weekStartsOn, system],
+ );
+ const activeMonth = monthKeyOf(currentDate, system);
// Today on a clock in the calendar's time zone.
const today = displayNow();
@@ -463,7 +456,7 @@ function MonthViewInner({
variant={fill ? 'full' : 'compact'}
height={fullRowHeight}
days={days}
- activeMask={monthMask(days, activeMonth)}
+ activeMask={monthMask(days, activeMonth, system)}
selectedIndex={dayIndexIn(days, selectedDate)}
todayIndex={dayIndexIn(days, today)}
index={index}
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/RecurrenceEditor.tsx b/src/components/calendar/RecurrenceEditor.tsx
index e7d28cb8..e2bd7f56 100644
--- a/src/components/calendar/RecurrenceEditor.tsx
+++ b/src/components/calendar/RecurrenceEditor.tsx
@@ -8,6 +8,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { Button } from '..';
import { useCalendarLocale } from '../../lib/calendar-locale';
+import { formatCalendarDay } from '../../lib/calendar-system';
import {
EDITOR_FREQUENCIES,
UNIT_LABEL_KEYS,
@@ -113,7 +114,7 @@ function NumberInput({
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
- const { locale, t } = useCalendarLocale();
+ const { locale, t, system } = useCalendarLocale();
const [value, setValue] = React.useState(() =>
editorValueFromRule(rule, eventStart),
@@ -245,7 +246,9 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
{value.endsMode === 'on' && (
setShowUntilPicker(true)}>
- {format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })}
+ {system.jalali
+ ? formatCalendarDay(parseISO(value.untilDate), locale, 'monthDayYear')
+ : format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })}
)}
diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx
index 9a44116f..046502ed 100644
--- a/src/components/calendar/TasksSheet.tsx
+++ b/src/components/calendar/TasksSheet.tsx
@@ -42,7 +42,7 @@ import {
import { askBeforeDiscard } from '../../lib/calendar-editor-discard';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
-import { formatCalendarDate } from '../../lib/date-format';
+import { formatCalendarDay } from '../../lib/calendar-system';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
@@ -233,8 +233,8 @@ export function TasksSheet({
if (!d || !task.due) return null;
const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due);
return hasTime
- ? `${formatCalendarDate(d, locale.code, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}`
- : formatCalendarDate(d, locale.code, 'weekdayMonthDay');
+ ? `${formatCalendarDay(d, locale, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}`
+ : formatCalendarDay(d, locale, 'weekdayMonthDay');
};
const priorityColor = (level: PriorityLevel): string =>
@@ -321,7 +321,7 @@ export function TasksSheet({
>
- {editor.due ? formatCalendarDate(editor.due, locale.code, 'monthDay') : t('calendar.tasks.due_date', 'Due date')}
+ {editor.due ? formatCalendarDay(editor.due, locale, 'monthDay') : t('calendar.tasks.due_date', 'Due date')}
{editor.due && (
void;
}) {
+ const { system } = useCalendarLocale();
return (
onPress?.(day)}>
@@ -577,7 +579,7 @@ const DayHeaderCell = React.memo(function DayHeaderCell({
selected && !today && styles.dayHeaderNumberTextSelected,
]}
>
- {format(day, 'd')}
+ {dayNumberLabel(day, system)}
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
index cfa39e0f..fb47be4b 100644
--- a/src/components/calendar/WeekView.tsx
+++ b/src/components/calendar/WeekView.tsx
@@ -13,6 +13,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
+import { dayNumberLabel } from '../../lib/calendar-system';
import { eventAccessibilityLabel } from '../../lib/calendar-a11y';
import {
buildEventDayIndex,
@@ -70,7 +71,7 @@ function WeekViewInner({
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const styles = React.useMemo(() => makeStyles(c), [c]);
- const { locale, t } = useCalendarLocale();
+ const { locale, t, system } = useCalendarLocale();
const scrollRef = React.useRef(null);
const index = React.useMemo(
@@ -171,7 +172,7 @@ function WeekViewInner({
selected && !today && styles.dayHeaderNumberTextSelected,
]}
>
- {format(day, 'd')}
+ {dayNumberLabel(day, system)}
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(' ({
+ client: { isConnected: true, accountId: 'u', currentSession: { apiUrl: 'https://mail.example/jmap/' } as unknown },
+ list: vi.fn(),
+ destroy: vi.fn(async (_ids: string[]) => undefined),
+ refresh: vi.fn(async () => undefined),
+ toast: { info: vi.fn(), warning: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() },
+}));
+
+vi.mock('react-native', () => ({ AppState: { addEventListener: vi.fn() } }));
+vi.mock('../../api/jmap-client', () => ({ jmapClient: h.client }));
+vi.mock('../../api/calendar-event-notifications', () => ({
+ getCalendarEventNotifications: h.list,
+ destroyCalendarEventNotifications: h.destroy,
+}));
+vi.mock('../../stores/auth-store', () => ({ useAuthStore: { subscribe: vi.fn() } }));
+vi.mock('../../stores/calendar-store', () => ({ useCalendarStore: { getState: () => ({ refresh: h.refresh }) } }));
+vi.mock('../../stores/toast-store', () => ({ toast: h.toast }));
+vi.mock('../../stores/locale-store', () => ({
+ t: (_key: string, fallback?: string, params?: Record) =>
+ (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => params?.[k] ?? ''),
+}));
+vi.mock('../capabilities', () => ({ hasCalendarCapability: () => true }));
+
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ eventNotificationToast,
+ pullCalendarEventNotifications,
+ resetCalendarEventNotifications,
+ startCalendarEventNotificationSync,
+} from '../calendar-event-notifications';
+import { dispatchStateChange } from '../state-change-bus';
+import type { CalendarEventNotification } from '../../api/calendar-event-notifications';
+
+// Ported from the webmail's components/layout/__tests__/calendar-event-notification-toaster.test.tsx
+// and lib/__tests__/pending-notification-store.test.ts.
+
+const t = (_key: string, fallback?: string, params?: Record) =>
+ (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
+
+function note(id: string, extra: Partial = {}): CalendarEventNotification {
+ return {
+ id,
+ created: '2026-10-31T05:00:00Z',
+ changedBy: { name: 'Land', email: 'land@example.org', principalId: 't' },
+ comment: null,
+ type: 'updated',
+ calendarEventId: 'e1',
+ isDraft: false,
+ event: { title: 'Planning' },
+ ...extra,
+ };
+}
+
+beforeEach(() => {
+ resetCalendarEventNotifications();
+ h.client.accountId = 'u';
+ h.list.mockReset();
+ h.destroy.mockClear();
+ h.refresh.mockClear();
+ for (const fn of Object.values(h.toast)) fn.mockClear();
+});
+
+describe('the toast for a calendar event notification', () => {
+ it('says who invited, changed or cancelled which event', () => {
+ expect(eventNotificationToast(note('1', { type: 'created' }), t)).toEqual({ type: 'info', title: 'Land invited you to "Planning"', message: undefined });
+ expect(eventNotificationToast(note('2'), t)).toEqual({ type: 'info', title: 'Land updated "Planning"', message: undefined });
+ expect(eventNotificationToast(note('3', { type: 'destroyed', comment: 'Sorry, ill' }), t))
+ .toEqual({ type: 'warning', title: 'Land cancelled "Planning"', message: 'Sorry, ill' });
+ });
+
+ it('falls back to the address, then "Someone", and to an untitled event', () => {
+ expect(eventNotificationToast(note('1', { changedBy: { email: 'x@example.org' }, event: null }), t)!.title)
+ .toBe('x@example.org updated "Untitled event"');
+ expect(eventNotificationToast(note('1', { changedBy: null }), t)!.title).toBe('Someone updated "Planning"');
+ });
+
+ it('shows nothing for a draft, the user’s own unsent change', () => {
+ expect(eventNotificationToast(note('1', { isDraft: true }), t)).toBeNull();
+ });
+});
+
+describe('pulling calendar event notifications', () => {
+ it('shows each new one, refreshes the calendar and acknowledges them on the server', async () => {
+ h.list.mockResolvedValue([note('a', { type: 'created' }), note('b', { type: 'destroyed' }), note('c', { isDraft: true })]);
+ await pullCalendarEventNotifications();
+ expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined);
+ expect(h.toast.warning).toHaveBeenCalledWith('Land cancelled "Planning"', undefined);
+ expect(h.toast.info).toHaveBeenCalledTimes(1);
+ expect(h.refresh).toHaveBeenCalledTimes(1);
+ // Drafts are acknowledged too, as the webmail does.
+ expect(h.destroy).toHaveBeenCalledWith(['a', 'b', 'c']);
+ });
+
+ it('does not show one again when the server still lists it', async () => {
+ h.list.mockResolvedValue([note('a')]);
+ await pullCalendarEventNotifications();
+ await pullCalendarEventNotifications();
+ expect(h.toast.info).toHaveBeenCalledTimes(1);
+ expect(h.destroy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing when there is nothing new', async () => {
+ h.list.mockResolvedValue([]);
+ await pullCalendarEventNotifications();
+ expect(h.refresh).not.toHaveBeenCalled();
+ expect(h.destroy).not.toHaveBeenCalled();
+ });
+
+ it('shows nothing from the previous account after a switch during the fetch', async () => {
+ h.list.mockImplementation(async () => {
+ h.client.accountId = 'other';
+ return [note('a')];
+ });
+ await pullCalendarEventNotifications();
+ expect(h.toast.info).not.toHaveBeenCalled();
+ expect(h.destroy).not.toHaveBeenCalled();
+ });
+
+ it('pulls again when a push arrives during a pull', async () => {
+ let release!: () => void;
+ h.list.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve([note('a')]); }));
+ h.list.mockResolvedValueOnce([note('a'), note('b')]);
+ const first = pullCalendarEventNotifications();
+ const second = pullCalendarEventNotifications();
+ release();
+ await Promise.all([first, second]);
+ expect(h.list).toHaveBeenCalledTimes(2);
+ expect(h.toast.info).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps quiet when the server refuses the listing', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ h.list.mockRejectedValue(new Error('unknownMethod'));
+ await expect(pullCalendarEventNotifications()).resolves.toBeUndefined();
+ expect(h.toast.info).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
+});
+
+describe('when notifications are pulled', () => {
+ it('pulls on a CalendarEventNotification push', async () => {
+ vi.useFakeTimers();
+ try {
+ h.list.mockResolvedValue([]);
+ startCalendarEventNotificationSync();
+ await vi.advanceTimersByTimeAsync(1500);
+ h.list.mockClear();
+ h.list.mockResolvedValue([note('p', { type: 'created' })]);
+ dispatchStateChange({ '@type': 'StateChange', changed: { u: { CalendarEventNotification: 's2' } } });
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(h.list).toHaveBeenCalledTimes(1);
+ expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('starts with the reminder sync, which the app starts at launch', () => {
+ const source = fs.readFileSync(path.resolve(__dirname, '../calendar-notifications.ts'), 'utf8');
+ const start = source.slice(source.indexOf('export function startCalendarNotificationSync'));
+ expect(start).toMatch(/syncStarted = true;[\s\S]*startCalendarEventNotificationSync\(\);/);
+ });
+});
diff --git a/src/lib/__tests__/calendar-notifications.test.ts b/src/lib/__tests__/calendar-notifications.test.ts
index 4b995773..41a73b18 100644
--- a/src/lib/__tests__/calendar-notifications.test.ts
+++ b/src/lib/__tests__/calendar-notifications.test.ts
@@ -1,4 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
type Listener = (state: Record, prev: Record) => void;
@@ -24,6 +26,8 @@ const h = vi.hoisted(() => ({
// Device sync (#34): which events the device's calendar app reminds of.
onDevice: null as ((event: { id: string }) => boolean) | null,
reminderListener: null as (() => void) | null,
+ // The reminder channel earlier versions created, if any.
+ legacyChannel: null as { id: string; importance: number } | null,
}));
vi.mock('react-native', () => ({
@@ -35,6 +39,8 @@ vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(async () => ({ ...h.permission })),
requestPermissionsAsync: vi.fn(async () => ({ granted: true })),
setNotificationChannelAsync: vi.fn(async () => undefined),
+ getNotificationChannelAsync: vi.fn(async (id: string) => (h.legacyChannel?.id === id ? h.legacyChannel : null)),
+ deleteNotificationChannelAsync: vi.fn(async () => undefined),
getAllScheduledNotificationsAsync: vi.fn(async () => h.pending),
cancelScheduledNotificationAsync: vi.fn(async () => undefined),
scheduleNotificationAsync: vi.fn(async () => 'id'),
@@ -46,7 +52,7 @@ vi.mock('expo-notifications', () => ({
return { remove: vi.fn() };
}),
DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT',
- AndroidImportance: { HIGH: 4 },
+ AndroidImportance: { NONE: 2, DEFAULT: 5, HIGH: 6 },
IosAuthorizationStatus: { PROVISIONAL: 3 },
SchedulableTriggerInputTypes: { DATE: 'date' },
}));
@@ -91,7 +97,7 @@ const upcoming = {
const upcomingKey = `ev1:a1:${new Date('2026-09-24T09:45:00Z').getTime()}`;
function reminder(identifier: string, key: string) {
- return { identifier, content: { data: { tag: TAG, key } } };
+ return { identifier, content: { data: { tag: TAG, key, delivery: 'calendar-reminders-involved' } } };
}
let mod: typeof import('../calendar-notifications');
@@ -115,6 +121,7 @@ beforeEach(async () => {
h.calendarListener = null;
h.onDevice = null;
h.reminderListener = null;
+ h.legacyChannel = null;
h.loadEventsInRange.mockResolvedValue([upcoming]);
mod = await import('../calendar-notifications');
N = await import('expo-notifications');
@@ -322,6 +329,7 @@ describe('tapping a calendar reminder', () => {
tag: TAG,
key: expect.any(String),
locale: 'en',
+ delivery: 'calendar-reminders-involved',
eventId: 'acc-2:ev1:2026-09-24T10:00:00Z',
kind: 'event',
serverId: 'ev1',
@@ -366,3 +374,55 @@ describe('tapping a calendar reminder', () => {
expect(open).toHaveBeenCalledTimes(1);
});
});
+
+describe('the Involved sound on calendar reminders', () => {
+ it('schedules reminders on a channel of its own that plays the Involved sound', async () => {
+ await mod.rescheduleCalendarNotifications();
+ expect(N.setNotificationChannelAsync).toHaveBeenCalledWith('calendar-reminders-involved', expect.objectContaining({
+ sound: 'involved_notification.mp3',
+ }));
+ const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0];
+ expect(request.trigger).toMatchObject({ channelId: 'calendar-reminders-involved' });
+ expect(request.content.sound).toBe('involved_notification.mp3');
+ });
+
+ it('leaves the channel of earlier versions alone', async () => {
+ h.legacyChannel = { id: 'calendar-reminders', importance: 6 };
+ await mod.rescheduleCalendarNotifications();
+ const ids = vi.mocked(N.setNotificationChannelAsync).mock.calls.map((call) => call[0]);
+ expect(ids).toEqual(['calendar-reminders-involved']);
+ expect(N.deleteNotificationChannelAsync).not.toHaveBeenCalled();
+ expect(vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0].trigger).toMatchObject({ channelId: 'calendar-reminders-involved' });
+ });
+
+ it('keeps reminders off for someone who switched the old channel off', async () => {
+ h.legacyChannel = { id: 'calendar-reminders', importance: 2 };
+ await mod.rescheduleCalendarNotifications();
+ expect(N.setNotificationChannelAsync).not.toHaveBeenCalled();
+ expect(vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0].trigger).toMatchObject({ channelId: 'calendar-reminders' });
+ });
+
+ it('schedules again a reminder pending from before, so it plays the new sound', async () => {
+ h.pending = [{ identifier: 'old', content: { data: { tag: TAG, key: upcomingKey, locale: 'en' } } }];
+ await mod.rescheduleCalendarNotifications();
+ expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('old');
+ const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0];
+ expect(request.content.data).toMatchObject({ key: upcomingKey, delivery: 'calendar-reminders-involved' });
+
+ // Once moved, it stays.
+ h.pending = [{ identifier: 'new', content: { data: request.content.data as Record } }];
+ vi.mocked(N.scheduleNotificationAsync).mockClear();
+ vi.mocked(N.cancelScheduledNotificationAsync).mockClear();
+ await mod.rescheduleCalendarNotifications();
+ expect(N.cancelScheduledNotificationAsync).not.toHaveBeenCalled();
+ expect(N.scheduleNotificationAsync).not.toHaveBeenCalled();
+ });
+
+ it('ships the sound for Android, kept from the resource shrinker, and for iOS', () => {
+ const root = path.resolve(__dirname, '../../..');
+ expect(fs.existsSync(path.join(root, 'android/app/src/main/res/raw/involved_notification.mp3'))).toBe(true);
+ expect(fs.readFileSync(path.join(root, 'android/app/src/main/res/raw/keep.xml'), 'utf8')).toContain('@raw/involved_notification');
+ expect(fs.existsSync(path.join(root, 'assets/sounds/involved_notification.wav'))).toBe(true);
+ expect(fs.readFileSync(path.join(root, 'app.config.js'), 'utf8')).toContain("sounds: ['./assets/sounds/involved_notification.wav']");
+ });
+});
diff --git a/src/lib/__tests__/calendar-system.test.ts b/src/lib/__tests__/calendar-system.test.ts
new file mode 100644
index 00000000..96ec6301
--- /dev/null
+++ b/src/lib/__tests__/calendar-system.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { enUS } from 'date-fns/locale/en-US';
+import { faIR } from 'date-fns/locale/fa-IR';
+import { fromJalali, isLeapJalaliYear, jalaliMonthLength, toJalali } from '../jalali';
+import {
+ calendarSystemFor,
+ dayNumberLabel,
+ dayRangeLabel,
+ formatCalendarDay,
+ gregorianCalendar,
+ isFirstOfMonth,
+ jalaliCalendar,
+ monthGridRows,
+ monthStartLabel,
+ monthTitle,
+} from '../calendar-system';
+import { baseRange } from '../calendar-scroll-window';
+import { monthKeyOf, monthMask } from '../calendar-month-scroll';
+
+// Ported from the webmail's lib/__tests__/jalali-utils.test.ts and
+// hooks/__tests__/use-calendar-locale.test.ts. The conversion was also
+// checked day by day from 1900 to 2200 against jalaali-js, which the webmail
+// uses.
+
+const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
+
+describe('Jalali dates', () => {
+ it('converts Gregorian days to Jalali and back', () => {
+ expect(toJalali(new Date(2026, 2, 21))).toEqual({ jy: 1405, jm: 1, jd: 1 });
+ expect(toJalali(new Date(2026, 3, 29))).toEqual({ jy: 1405, jm: 2, jd: 9 });
+ expect(toJalali(new Date(2025, 2, 20))).toEqual({ jy: 1403, jm: 12, jd: 30 });
+ expect(ymd(fromJalali(1405, 7, 1))).toEqual([2026, 9, 23]);
+ expect(ymd(fromJalali(1403, 12, 30))).toEqual([2025, 3, 20]);
+ });
+
+ it('knows leap years and month lengths', () => {
+ expect(isLeapJalaliYear(1403)).toBe(true);
+ expect(isLeapJalaliYear(1404)).toBe(false);
+ expect(jalaliMonthLength(1404, 1)).toBe(31);
+ expect(jalaliMonthLength(1404, 7)).toBe(30);
+ expect(jalaliMonthLength(1404, 12)).toBe(29);
+ expect(jalaliMonthLength(1403, 12)).toBe(30);
+ });
+});
+
+describe('the calendar system', () => {
+ it('is Jalali for the Persian UI only, as in the webmail', () => {
+ expect(calendarSystemFor('fa')).toBe(jalaliCalendar);
+ expect(calendarSystemFor('de')).toBe(gregorianCalendar);
+ expect(calendarSystemFor('ar')).toBe(gregorianCalendar);
+ });
+
+ it('builds the Jalali month grid from the week of 1 Mehr to the week of 30 Mehr, starting on Saturday', () => {
+ // 15 October 2026 is 23 Mehr 1405.
+ const rows = monthGridRows(new Date(2026, 9, 15), 6, jalaliCalendar);
+ const first = rows[0][0];
+ const last = rows[rows.length - 1][6];
+ expect(first.getDay()).toBe(6);
+ expect(rows.flat().some((d) => toJalali(d).jm === 7 && toJalali(d).jd === 1)).toBe(true);
+ expect(toJalali(rows[0][6]).jm === 7 || toJalali(rows[0][6]).jd === 1).toBe(true);
+ expect(rows.flat().some((d) => toJalali(d).jm === 7 && toJalali(d).jd === 30)).toBe(true);
+ // No whole week of another month at either end.
+ expect(rows[0].some((d) => toJalali(d).jm === 7)).toBe(true);
+ expect(rows[rows.length - 1].some((d) => toJalali(d).jm === 7)).toBe(true);
+ expect(last.getDay()).toBe(5);
+ });
+
+ it('starts a Gregorian month grid on Saturday when the week does', () => {
+ const rows = monthGridRows(new Date(2026, 9, 15), 6, gregorianCalendar);
+ expect(rows[0][0].getDay()).toBe(6);
+ expect(ymd(rows[0][0])).toEqual([2026, 9, 26]);
+ expect(ymd(rows[rows.length - 1][6])).toEqual([2026, 11, 6]);
+ });
+
+ it('gives the scrolling month the Jalali grid, and the Gregorian one without a calendar system', () => {
+ const date = new Date(2026, 9, 15);
+ const jalali = baseRange('month', date, { weekStartsOn: 6, calendarSystem: jalaliCalendar });
+ const rows = monthGridRows(date, 6, jalaliCalendar);
+ expect(jalali.start.getTime()).toBe(rows[0][0].getTime());
+ expect(jalali.end.getTime()).toBe(rows[rows.length - 1][6].getTime());
+ const gregorian = baseRange('month', date, { weekStartsOn: 1 });
+ expect(ymd(gregorian.start)).toEqual([2026, 9, 28]);
+ });
+
+ it('counts two days of Mehr in September and October as one month, and shades by it', () => {
+ const sept = new Date(2026, 8, 25); // 3 Mehr
+ const oct = new Date(2026, 9, 20); // 28 Mehr
+ expect(monthKeyOf(sept, jalaliCalendar)).toBe(monthKeyOf(oct, jalaliCalendar));
+ expect(monthKeyOf(sept)).not.toBe(monthKeyOf(oct));
+ const days = [new Date(2026, 9, 21), new Date(2026, 9, 22), new Date(2026, 9, 23)]; // 29, 30 Mehr, 1 Aban
+ expect(monthMask(days, monthKeyOf(oct, jalaliCalendar), jalaliCalendar)).toBe(0b011);
+ });
+
+ it('steps Jalali months, keeping the day where the month has it', () => {
+ // 31 Shahrivar 1405 -> 30 Mehr (Mehr has 30 days).
+ expect(toJalali(jalaliCalendar.addMonths(fromJalali(1405, 6, 31), 1))).toEqual({ jy: 1405, jm: 7, jd: 30 });
+ expect(toJalali(jalaliCalendar.addMonths(fromJalali(1404, 12, 10), 1))).toEqual({ jy: 1405, jm: 1, jd: 10 });
+ expect(toJalali(jalaliCalendar.addMonths(fromJalali(1405, 1, 10), -1))).toEqual({ jy: 1404, jm: 12, jd: 10 });
+ const withTime = new Date(2026, 9, 15, 14, 30);
+ expect(jalaliCalendar.addMonths(withTime, 2).getHours()).toBe(14);
+ });
+
+ it('labels Jalali days and months as the webmail does', () => {
+ const day = new Date(2026, 3, 29); // 9 Ordibehesht 1405
+ expect(dayNumberLabel(day, jalaliCalendar)).toBe('9');
+ expect(dayNumberLabel(day, gregorianCalendar)).toBe('29');
+ expect(monthTitle(day, jalaliCalendar, faIR, true)).toBe('اردیبهشت 1405');
+ expect(monthTitle(day, jalaliCalendar, faIR, false)).toBe('اردیبهشت');
+ expect(monthTitle(day, gregorianCalendar, enUS, true)).toBe('April 2026');
+ expect(isFirstOfMonth(new Date(2026, 3, 21), jalaliCalendar)).toBe(true);
+ expect(monthStartLabel(new Date(2026, 3, 21), jalaliCalendar, faIR, true)).toBe('1 اردیبهشت');
+ expect(monthStartLabel(new Date(2026, 4, 1), gregorianCalendar, enUS, true)).toBe('May 1');
+ expect(dayRangeLabel(new Date(2026, 3, 25), new Date(2026, 4, 1), jalaliCalendar, faIR)).toBe('5 – 11 اردیبهشت');
+ expect(dayRangeLabel(new Date(2026, 3, 18), new Date(2026, 3, 24), jalaliCalendar, faIR)).toBe('29 فروردین – 4 اردیبهشت');
+ });
+
+ it('writes event dates with the Jalali day, month and year in Persian (useFormatEventDate)', () => {
+ const day = new Date(2026, 3, 29);
+ expect(formatCalendarDay(day, faIR, 'longWeekdayMonthDayYear')).toBe('چهارشنبه, 9 اردیبهشت 1405');
+ expect(formatCalendarDay(day, faIR, 'monthDay')).toBe('9 اردیبهشت');
+ expect(formatCalendarDay(day, enUS, 'monthDay')).toMatch(/Apr(il)? 29/);
+ });
+});
+
+describe('the views follow the calendar system', () => {
+ const read = (p: string) => fs.readFileSync(path.resolve(__dirname, p), 'utf8');
+
+ it('builds the month grid and its shading from the calendar system', () => {
+ const month = read('../../components/calendar/MonthView.tsx');
+ expect(month).toMatch(/monthGridRows\(currentDate, weekStartsOn, system\)/);
+ expect(month).toMatch(/monthMask\(days, activeMonth, system\)/);
+ expect(month).toMatch(/dayNumberLabel\(d, system\)/);
+ const scroll = read('../../components/calendar/MonthScrollView.tsx');
+ expect(scroll).toMatch(/calendarSystem: system/);
+ expect(scroll).toMatch(/monthMask\(days, activeMonth, system\)/);
+ });
+
+ it('steps a month and titles the screen in the calendar system', () => {
+ const screen = read('../../screens/CalendarScreen.tsx');
+ expect(screen).toMatch(/system\.addMonths\(base, -1\)/);
+ expect(screen).toMatch(/system\.addMonths\(base, 1\)/);
+ expect(screen).toMatch(/headerTitle\(titleDate, locale, system\)/);
+ expect(screen).toMatch(/calendarSystem: system/);
+ });
+
+ it('numbers the days of the week and day views in the calendar system', () => {
+ expect(read('../../components/calendar/WeekView.tsx')).toMatch(/dayNumberLabel\(day, system\)/);
+ expect(read('../../components/calendar/TimeGridScrollView.tsx')).toMatch(/dayNumberLabel\(day, system\)/);
+ expect(read('../../components/calendar/AgendaView.tsx')).toMatch(/dayNumberLabel\(date, system\)/);
+ });
+});
diff --git a/src/lib/calendar-a11y.ts b/src/lib/calendar-a11y.ts
index 98cb478f..97fcbd6c 100644
--- a/src/lib/calendar-a11y.ts
+++ b/src/lib/calendar-a11y.ts
@@ -3,7 +3,7 @@ import type { Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../api/types';
import { getEventDisplayEndDate, getEventStartDate, getPrimaryCalendarId, timePattern } from './calendar-utils';
import { isDeclinedByUser } from './calendar-participants';
-import { formatCalendarDate } from './date-format';
+import { formatCalendarDay } from './calendar-system';
type Translate = (key: string, fallback?: string, params?: Record) => string;
@@ -27,7 +27,7 @@ export function participantRemoveAccessibilityLabel(attendee: { name?: string; e
/** "Monday, November 2, 2026, 3 events" for a month day cell. */
export function dayCellAccessibilityLabel(day: Date, eventCount: number, t: Translate, locale: Locale): string {
- const date = formatCalendarDate(day, locale.code, 'longWeekdayMonthDayYear');
+ const date = formatCalendarDay(day, locale, 'longWeekdayMonthDayYear');
if (eventCount === 0) return date;
return `${date}, ${t('calendar.a11y.event_count', '{count, plural, one {# event} other {# events}}', { count: eventCount })}`;
}
@@ -49,10 +49,10 @@ export function eventAccessibilityLabel(
const end = getEventDisplayEndDate(event);
const valid = (d: Date) => !isNaN(d.getTime());
if (valid(start)) {
- const date = formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear');
+ const date = formatCalendarDay(start, locale, 'longWeekdayMonthDayYear');
if (event.showWithoutTime) {
const last = valid(end) && !isSameDay(start, end)
- ? ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')}`
+ ? ` – ${formatCalendarDay(end, locale, 'longWeekdayMonthDayYear')}`
: '';
parts.push(date + last, t('calendar.events.all_day', 'All day'));
} else {
@@ -61,7 +61,7 @@ export function eventAccessibilityLabel(
if (valid(end) && end.getTime() > start.getTime()) {
range += isSameDay(start, end)
? ` – ${format(end, time, { locale })}`
- : ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`;
+ : ` – ${formatCalendarDay(end, locale, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`;
}
parts.push(date, range);
}
diff --git a/src/lib/calendar-event-notifications.ts b/src/lib/calendar-event-notifications.ts
new file mode 100644
index 00000000..b25e7d2b
--- /dev/null
+++ b/src/lib/calendar-event-notifications.ts
@@ -0,0 +1,154 @@
+import { AppState } from 'react-native';
+import { jmapClient } from '../api/jmap-client';
+import {
+ destroyCalendarEventNotifications,
+ getCalendarEventNotifications,
+ type CalendarEventNotification,
+} from '../api/calendar-event-notifications';
+import { useAuthStore } from '../stores/auth-store';
+import { useCalendarStore } from '../stores/calendar-store';
+import { t as translate } from '../stores/locale-store';
+import { toast } from '../stores/toast-store';
+import { hasCalendarCapability } from './capabilities';
+import { onStateChangeType } from './state-change-bus';
+
+// Someone invited the user, changed or cancelled an event the user takes
+// part in: the server keeps a CalendarEventNotification for it. They are
+// pulled at sign-in, on every `CalendarEventNotification` push and when the
+// app comes back to the foreground, shown as toasts, the events in view are
+// refreshed so the change shows, and the notifications are acknowledged,
+// which destroys them on the server. Ported from the webmail's
+// stores/calendar-event-notification-store.ts,
+// lib/pending-notification-store.ts and
+// components/layout/calendar-event-notification-toaster.tsx.
+
+type Translate = (key: string, fallback?: string, params?: Record) => string;
+
+export interface EventNotificationToast {
+ type: 'info' | 'warning';
+ title: string;
+ message?: string;
+}
+
+/** The toast for one notification; null for a draft (the user's own unsent change). */
+export function eventNotificationToast(n: CalendarEventNotification, t: Translate): EventNotificationToast | null {
+ if (n.isDraft) return null;
+ const kind = n.type === 'created' ? 'invited' : n.type === 'destroyed' ? 'cancelled' : 'updated';
+ const params = {
+ name: n.changedBy?.name || n.changedBy?.email || t('calendar_event_notifications.someone', 'Someone'),
+ title: n.event?.title || t('calendar_event_notifications.untitled', 'Untitled event'),
+ };
+ const title = kind === 'invited'
+ ? t('calendar_event_notifications.invited', '{name} invited you to "{title}"', params)
+ : kind === 'cancelled'
+ ? t('calendar_event_notifications.cancelled', '{name} cancelled "{title}"', params)
+ : t('calendar_event_notifications.updated', '{name} updated "{title}"', params);
+ return { type: kind === 'cancelled' ? 'warning' : 'info', title, message: n.comment || undefined };
+}
+
+// Ids already shown in this session; a pull racing an acknowledgement must
+// not show them again.
+const seen = new Set();
+let seenFor: string | null = null;
+let inFlight: Promise | null = null;
+let again = false;
+let syncStarted = false;
+let pullTimer: ReturnType | null = null;
+
+function sessionKey(): string | null {
+ try {
+ return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Fetch the pending notifications, show the new ones, refresh the calendar
+ * and acknowledge them. Concurrent calls coalesce; a push arriving while a
+ * pull runs pulls again afterwards. Never rejects.
+ */
+export function pullCalendarEventNotifications(): Promise {
+ if (inFlight) {
+ again = true;
+ return inFlight;
+ }
+ inFlight = (async () => {
+ try {
+ do {
+ again = false;
+ await pullOnce();
+ } while (again);
+ } finally {
+ inFlight = null;
+ }
+ })();
+ return inFlight;
+}
+
+async function pullOnce(): Promise {
+ if (!jmapClient.isConnected || !hasCalendarCapability()) return;
+ const key = sessionKey();
+ if (!key) return;
+ if (key !== seenFor) {
+ seen.clear();
+ seenFor = key;
+ }
+ let list: CalendarEventNotification[];
+ try {
+ list = await getCalendarEventNotifications();
+ } catch (error) {
+ console.warn('[calendar] Failed to fetch calendar event notifications:', error);
+ return;
+ }
+ // Another account by now: its own pull shows its own.
+ if (sessionKey() !== key) return;
+ const fresh = list.filter((n) => !seen.has(n.id));
+ if (fresh.length === 0) return;
+ for (const n of fresh) {
+ seen.add(n.id);
+ const shown = eventNotificationToast(n, translate as Translate);
+ if (shown) toast[shown.type](shown.title, shown.message);
+ }
+ void useCalendarStore.getState().refresh().catch(() => undefined);
+ try {
+ await destroyCalendarEventNotifications(fresh.map((n) => n.id));
+ } catch (error) {
+ // Worst case they are shown again after a restart.
+ console.warn('[calendar] Failed to acknowledge calendar event notifications:', error);
+ }
+}
+
+function pullSoon(): void {
+ if (pullTimer) clearTimeout(pullTimer);
+ // Sign-in fans out its own requests first; a burst of pushes is one pull.
+ pullTimer = setTimeout(() => {
+ pullTimer = null;
+ void pullCalendarEventNotifications();
+ }, 1000);
+}
+
+/** Pull event notifications for the rest of the session. Idempotent. */
+export function startCalendarEventNotificationSync(): void {
+ if (syncStarted) return;
+ syncStarted = true;
+ onStateChangeType('CalendarEventNotification', pullSoon);
+ useAuthStore.subscribe((state, prev) => {
+ if (state.session !== prev.session && state.session) pullSoon();
+ });
+ // Pushes stop while the app is in the background.
+ AppState.addEventListener('change', (state) => {
+ if (state === 'active') pullSoon();
+ });
+ pullSoon();
+}
+
+/** Forget what was shown (tests). */
+export function resetCalendarEventNotifications(): void {
+ seen.clear();
+ seenFor = null;
+ inFlight = null;
+ again = false;
+ if (pullTimer) clearTimeout(pullTimer);
+ pullTimer = null;
+}
diff --git a/src/lib/calendar-free-busy.ts b/src/lib/calendar-free-busy.ts
new file mode 100644
index 00000000..35467c08
--- /dev/null
+++ b/src/lib/calendar-free-busy.ts
@@ -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)));
+}
diff --git a/src/lib/calendar-locale.ts b/src/lib/calendar-locale.ts
index 5f857c66..f929c24c 100644
--- a/src/lib/calendar-locale.ts
+++ b/src/lib/calendar-locale.ts
@@ -2,6 +2,7 @@ import type { Locale } from 'date-fns';
import { enUS } from 'date-fns/locale/en-US';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import type { LocaleCode } from '../i18n';
+import { calendarSystemFor, type CalendarSystem } from './calendar-system';
// date-fns locale for each UI language so month/day names, "EEE, MMM d"
// headers and the like render in the user's language (the webmail localizes
@@ -55,9 +56,12 @@ export function getDateFnsLocale(code: LocaleCode | string | null | undefined):
return locale;
}
-/** `{ locale }` options for date-fns `format()` plus the translate function. */
-export function useCalendarLocale(): { locale: Locale; t: TranslateFn } {
+/**
+ * `{ locale }` options for date-fns `format()`, the translate function and
+ * the calendar system (Jalali for Persian, as in the webmail).
+ */
+export function useCalendarLocale(): { locale: Locale; t: TranslateFn; system: CalendarSystem } {
const code = useLocaleStore((s) => s.locale);
const t = useLocaleStore((s) => s.t);
- return { locale: getDateFnsLocale(code), t };
+ return { locale: getDateFnsLocale(code), t, system: calendarSystemFor(code) };
}
diff --git a/src/lib/calendar-month-scroll.ts b/src/lib/calendar-month-scroll.ts
index 763c4d17..65423b5a 100644
--- a/src/lib/calendar-month-scroll.ts
+++ b/src/lib/calendar-month-scroll.ts
@@ -1,5 +1,6 @@
import { addDays, differenceInCalendarDays } from 'date-fns';
import { baseRange, type DayRange, type ScrollWindowOptions } from './calendar-scroll-window';
+import { gregorianCalendar, type CalendarSystem } from './calendar-system';
/**
* Geometry of the freely scrolling month view (#759, webmail
@@ -63,9 +64,9 @@ export function sampledRow(
return Math.max(0, Math.min(rowCount - 1, row));
}
-/** Year and month as one comparable number. */
-export function monthKeyOf(date: Date): number {
- return date.getFullYear() * 12 + date.getMonth();
+/** Year and month as one comparable number, in the calendar system's months. */
+export function monthKeyOf(date: Date, system: CalendarSystem = gregorianCalendar): number {
+ return system.monthKey(date);
}
/**
@@ -73,10 +74,10 @@ export function monthKeyOf(date: Date): number {
* compare this instead of the month itself, so moving to another month only
* re-renders the rows whose shading changes.
*/
-export function monthMask(days: Date[], monthKey: number): number {
+export function monthMask(days: Date[], monthKey: number, system: CalendarSystem = gregorianCalendar): number {
let mask = 0;
days.forEach((day, i) => {
- if (monthKeyOf(day) === monthKey) mask |= 1 << i;
+ if (monthKeyOf(day, system) === monthKey) mask |= 1 << i;
});
return mask;
}
diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts
index b7f4312b..835b9246 100644
--- a/src/lib/calendar-notifications.ts
+++ b/src/lib/calendar-notifications.ts
@@ -11,6 +11,7 @@ import { onStateChangeType } from './state-change-bus';
import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler';
import type { CalendarReminderTarget } from '../navigation/pending-calendar-open';
import { onDeviceRemindersChange, remindedOnDevice } from '../device-sync/app/reminders';
+import { startCalendarEventNotificationSync } from './calendar-event-notifications';
// Local reminders for calendar events and tasks. The webmail polls
// `getPendingAlerts` every minute while a tab is open; a phone is mostly
@@ -21,7 +22,17 @@ import { onDeviceRemindersChange, remindedOnDevice } from '../device-sync/app/re
// don't depend on the Calendar tab being open. Honours the
// `calendarNotificationsEnabled` setting.
-const CHANNEL_ID = 'calendar-reminders';
+// Reminders play the webmail's default notification sound, Involved
+// (webmail lib/notification-sound.ts, stores/settings-store.ts
+// notificationSoundChoice). An Android channel keeps the sound it was created
+// with, so the Involved channel has an id of its own; the channel earlier
+// versions created stays as the user left it. Its sound file ships as
+// android/app/src/main/res/raw/involved_notification.mp3 and, for iOS,
+// assets/sounds/involved_notification.wav.
+export const CHANNEL_ID = 'calendar-reminders-involved';
+export const LEGACY_CHANNEL_ID = 'calendar-reminders';
+const ANDROID_SOUND = 'involved_notification.mp3';
+const IOS_SOUND = 'involved_notification.wav';
const DAY_MS = 24 * 60 * 60 * 1000;
const HORIZON_MS = 7 * DAY_MS;
// The events loaded for the horizon: from a day back (an end-relative alert
@@ -41,6 +52,8 @@ let permissionRequested = false;
let mayAskPermission = false;
// The language the Android channel was last named in; null until created.
let channelLocale: string | null = null;
+// The channel reminders go to; decided once per run.
+let activeChannelId: string | null = null;
let rescheduleTimer: ReturnType | null = null;
let rescheduling: Promise | null = null;
let queued = false;
@@ -67,25 +80,48 @@ async function ensurePermission(): Promise {
return permissionGranted;
}
+/**
+ * The channel reminders go to, given the channel earlier versions created
+ * (null when there is none). Someone who switched those reminders off in the
+ * system settings keeps them off: they stay on that channel. Everyone else
+ * gets the Involved channel.
+ */
+export function reminderChannelFor(legacy: { importance?: number } | null): string {
+ if (legacy && legacy.importance === Notifications.AndroidImportance.NONE) return LEGACY_CHANNEL_ID;
+ return CHANNEL_ID;
+}
+
// The channel's name shows in the system notification settings, so it is
// (re)named in the app language; setting an existing channel only renames it.
+// The old channel is never touched.
async function ensureChannel(): Promise {
if (Platform.OS !== 'android') return;
const { locale, t } = useLocaleStore.getState();
if (channelLocale === locale) return;
try {
- await Notifications.setNotificationChannelAsync(CHANNEL_ID, {
- name: t('calendar.notifications.channel_name', 'Calendar reminders'),
- importance: Notifications.AndroidImportance.HIGH,
- sound: 'default',
- vibrationPattern: [0, 250, 250, 250],
- });
+ if (activeChannelId === null) {
+ const legacy = await Notifications.getNotificationChannelAsync(LEGACY_CHANNEL_ID).catch(() => null);
+ activeChannelId = reminderChannelFor(legacy);
+ }
+ if (activeChannelId === CHANNEL_ID) {
+ await Notifications.setNotificationChannelAsync(CHANNEL_ID, {
+ name: t('calendar.notifications.channel_name', 'Calendar reminders'),
+ importance: Notifications.AndroidImportance.HIGH,
+ sound: ANDROID_SOUND,
+ vibrationPattern: [0, 250, 250, 250],
+ });
+ }
} catch {
// Channel creation failing only degrades to the default channel.
}
channelLocale = locale;
}
+/** What a reminder is scheduled with: its channel on Android, its sound on iOS. */
+function deliveryTag(): string {
+ return Platform.OS === 'android' ? activeChannelId ?? CHANNEL_ID : IOS_SOUND;
+}
+
function alertKeyOf(request: Notifications.NotificationRequest): string | null {
const data = request.content.data as { tag?: string; key?: string } | undefined;
return data?.tag === DATA_TAG && typeof data.key === 'string' ? data.key : null;
@@ -98,6 +134,13 @@ function isStaleLanguage(request: Notifications.NotificationRequest, locale: str
return typeof data?.locale === 'string' && data.locale !== locale;
}
+// A reminder scheduled for another channel or sound, such as the default one
+// before the Involved sound: scheduled again, so it plays the new sound.
+function isStaleDelivery(request: Notifications.NotificationRequest): boolean {
+ const data = request.content.data as { delivery?: unknown } | undefined;
+ return data?.delivery !== deliveryTag();
+}
+
/** Cancel every calendar reminder this app scheduled. */
export async function cancelAllCalendarNotifications(): Promise {
try {
@@ -118,6 +161,8 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise
key: alert.key,
// The language the title and body are in, to reword it on a switch.
locale,
+ // The channel (Android) or sound (iOS) it goes out with.
+ delivery: deliveryTag(),
eventId: alert.eventId,
kind: alert.kind,
// Enough to find the event again when the reminder is tapped.
@@ -132,13 +177,15 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise
content: {
title: alert.title,
body: alert.body,
- sound: 'default',
+ // iOS plays the bundled file, or the default sound without it. Android
+ // takes the channel's sound.
+ sound: Platform.OS === 'ios' ? IOS_SOUND : ANDROID_SOUND,
data,
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(alert.fireTimeMs),
- ...(Platform.OS === 'android' ? { channelId: CHANNEL_ID } : {}),
+ ...(Platform.OS === 'android' ? { channelId: activeChannelId ?? CHANNEL_ID } : {}),
},
});
}
@@ -250,7 +297,7 @@ export async function rescheduleCalendarNotifications(): Promise {
for (const request of pending) {
const key = alertKeyOf(request);
if (key === null) continue;
- if (wantedByKey.has(key) && !isStaleLanguage(request, locale)) {
+ if (wantedByKey.has(key) && !isStaleLanguage(request, locale) && !isStaleDelivery(request)) {
present.add(key);
} else {
await Notifications.cancelScheduledNotificationAsync(request.identifier);
@@ -369,6 +416,8 @@ export function startCalendarNotificationSync(options?: { askPermission?: boolea
if (syncStarted) return;
syncStarted = true;
installForegroundHandler();
+ // Invitations, changes and cancellations others made (CalendarEventNotification).
+ startCalendarEventNotificationSync();
// Edits in the app, a refresh after a push, tasks loading. Moving the
// calendar to another window changes no reminder.
useCalendarStore.subscribe((state, prev) => {
diff --git a/src/lib/calendar-scroll-window.ts b/src/lib/calendar-scroll-window.ts
index a8af49de..3e711b2c 100644
--- a/src/lib/calendar-scroll-window.ts
+++ b/src/lib/calendar-scroll-window.ts
@@ -8,6 +8,7 @@ import {
subDays,
} from 'date-fns';
import { dayKey } from './calendar-utils';
+import { monthGridRange, type CalendarSystem } from './calendar-system';
/**
* The calendar views scroll freely (#759; webmail lib/calendar-scroll-window):
@@ -50,6 +51,8 @@ export interface CalendarFocus {
export interface ScrollWindowOptions {
weekStartsOn: WeekStartsOn;
+ /** What a month is (Jalali for Persian); Gregorian when omitted. */
+ calendarSystem?: CalendarSystem;
}
/** First growth step in days; every further step doubles the side. */
@@ -114,6 +117,7 @@ export function baseRange(mode: ScrollViewMode, date: Date, opts: ScrollWindowOp
end: startOfDay(endOfWeek(day, { weekStartsOn: opts.weekStartsOn })),
};
case 'month':
+ if (opts.calendarSystem) return monthGridRange(day, opts.weekStartsOn, opts.calendarSystem);
return {
start: startOfWeek(startOfMonth(day), { weekStartsOn: opts.weekStartsOn }),
end: startOfDay(endOfWeek(endOfMonth(day), { weekStartsOn: opts.weekStartsOn })),
diff --git a/src/lib/calendar-system.ts b/src/lib/calendar-system.ts
new file mode 100644
index 00000000..aade4d92
--- /dev/null
+++ b/src/lib/calendar-system.ts
@@ -0,0 +1,198 @@
+import {
+ addDays,
+ addMonths as addGregorianMonths,
+ endOfWeek,
+ format,
+ startOfDay,
+ startOfWeek,
+ type Locale,
+} from 'date-fns';
+import { JALALI_MONTHS, fromJalali, jalaliMonthLength, toJalali } from './jalali';
+import { formatCalendarDate, type CalendarDateStyle } from './date-format';
+
+// The calendar system the calendar's months, day numbers and headers follow:
+// Gregorian, or Jalali for the Persian UI, as the webmail decides it
+// (lib/jalali-utils.ts shouldUseJalaliCalendar, hooks/use-calendar-locale.ts).
+// Dates stay Gregorian Dates everywhere; only what a month is, and how a day
+// or a month is labelled, changes.
+
+export type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6;
+
+export interface CalendarSystem {
+ jalali: boolean;
+ /** Local midnight of the first day of `date`'s month. */
+ startOfMonth(date: Date): Date;
+ /** Local midnight of the last day of `date`'s month. */
+ lastDayOfMonth(date: Date): Date;
+ /** The same day `n` months away, clamped to the end of a shorter month. */
+ addMonths(date: Date, n: number): Date;
+ /** Year and month as one comparable number. */
+ monthKey(date: Date): number;
+ /** Day of the month, 1-31. */
+ dayOfMonth(date: Date): number;
+ /** Month name, in the UI language (Persian names for Jalali). */
+ monthName(date: Date, width: 'long' | 'short', locale: Locale): string;
+ /** Year of `date`'s month. */
+ year(date: Date): number;
+}
+
+export const gregorianCalendar: CalendarSystem = {
+ jalali: false,
+ startOfMonth: (date) => new Date(date.getFullYear(), date.getMonth(), 1),
+ lastDayOfMonth: (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0),
+ addMonths: (date, n) => addGregorianMonths(date, n),
+ monthKey: (date) => date.getFullYear() * 12 + date.getMonth(),
+ dayOfMonth: (date) => date.getDate(),
+ // The long name stands alone ("LLLL": some languages decline it inside a
+ // date); the short one labels the 1st in a month grid.
+ monthName: (date, width, locale) => format(date, width === 'long' ? 'LLLL' : 'MMM', { locale }),
+ year: (date) => date.getFullYear(),
+};
+
+export const jalaliCalendar: CalendarSystem = {
+ jalali: true,
+ startOfMonth: (date) => {
+ const { jy, jm } = toJalali(date);
+ return fromJalali(jy, jm, 1);
+ },
+ lastDayOfMonth: (date) => {
+ const { jy, jm } = toJalali(date);
+ return fromJalali(jy, jm, jalaliMonthLength(jy, jm));
+ },
+ addMonths: (date, n) => {
+ const { jy, jm, jd } = toJalali(date);
+ const index = jy * 12 + (jm - 1) + n;
+ const y = Math.floor(index / 12);
+ const m = index - y * 12 + 1;
+ const day = fromJalali(y, m, Math.min(jd, jalaliMonthLength(y, m)));
+ // Keep the time of day, as date-fns addMonths does.
+ day.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
+ return day;
+ },
+ monthKey: (date) => {
+ const { jy, jm } = toJalali(date);
+ return jy * 12 + (jm - 1);
+ },
+ dayOfMonth: (date) => toJalali(date).jd,
+ // The webmail shows the Persian names, and the first three letters on a
+ // phone (formatMonthYearShort).
+ monthName: (date, width) => {
+ const name = JALALI_MONTHS[toJalali(date).jm - 1];
+ return width === 'long' ? name : name.slice(0, 3);
+ },
+ year: (date) => toJalali(date).jy,
+};
+
+/** Whether the calendar shows Jalali dates: keyed off the Persian UI, as in the webmail. */
+export function shouldUseJalaliCalendar(locale: string | null | undefined): boolean {
+ return locale === 'fa';
+}
+
+export function calendarSystemFor(locale: string | null | undefined): CalendarSystem {
+ return shouldUseJalaliCalendar(locale) ? jalaliCalendar : gregorianCalendar;
+}
+
+/** The days of `date`'s month grid: whole weeks from the one with the 1st to the one with the last day. */
+export function monthGridRange(
+ date: Date,
+ weekStartsOn: WeekStart,
+ system: CalendarSystem,
+): { start: Date; end: Date } {
+ return {
+ start: startOfWeek(system.startOfMonth(date), { weekStartsOn }),
+ end: startOfDay(endOfWeek(system.lastDayOfMonth(date), { weekStartsOn })),
+ };
+}
+
+/** `date`'s month grid as rows of seven days. */
+export function monthGridRows(date: Date, weekStartsOn: WeekStart, system: CalendarSystem): Date[][] {
+ const { start, end } = monthGridRange(date, weekStartsOn, system);
+ const rows: Date[][] = [];
+ for (let day = start; day <= end; day = addDays(day, 7)) {
+ rows.push(Array.from({ length: 7 }, (_, i) => addDays(day, i)));
+ }
+ return rows;
+}
+
+/** Whether `date` is the first day of its month. */
+export function isFirstOfMonth(date: Date, system: CalendarSystem): boolean {
+ return system.dayOfMonth(date) === 1;
+}
+
+/** "Mehr 1405" / "October 2026"; without the year when `withYear` is false. */
+export function monthTitle(date: Date, system: CalendarSystem, locale: Locale, withYear: boolean): string {
+ const name = system.monthName(date, 'long', locale);
+ if (!withYear) return name;
+ if (!system.jalali) return format(date, 'LLLL yyyy', { locale });
+ return `${name} ${system.year(date)}`;
+}
+
+/** Day number and month of `date`, e.g. "9 Ordibehesht" or "Apr 29". */
+export function shortDayLabel(date: Date, system: CalendarSystem, locale: Locale): string {
+ if (!system.jalali) return format(date, 'MMM d', { locale });
+ return `${system.dayOfMonth(date)} ${system.monthName(date, 'long', locale)}`;
+}
+
+/**
+ * A date with weekday, day, month and year, e.g. "Wed, Apr 29, 2026", or
+ * "چهارشنبه, 9 اردیبهشت 1405" for Jalali (webmail useFormatEventDate).
+ */
+export function fullDateLabel(date: Date, system: CalendarSystem, locale: Locale, gregorianPattern = 'EEE, PP'): string {
+ if (!system.jalali) return format(date, gregorianPattern, { locale });
+ const { jy, jm, jd } = toJalali(date);
+ return `${format(date, 'EEEE', { locale })}, ${jd} ${JALALI_MONTHS[jm - 1]} ${jy}`;
+}
+
+/** "6 – 12 Mehr" / "Oct 6 – 12": the days a range of days covers. */
+export function dayRangeLabel(start: Date, end: Date, system: CalendarSystem, locale: Locale): string {
+ if (!system.jalali) {
+ const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
+ return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`;
+ }
+ const a = toJalali(start);
+ const b = toJalali(end);
+ if (a.jy === b.jy && a.jm === b.jm) return `${a.jd} – ${b.jd} ${JALALI_MONTHS[a.jm - 1]}`;
+ return `${a.jd} ${JALALI_MONTHS[a.jm - 1]} – ${b.jd} ${JALALI_MONTHS[b.jm - 1]}`;
+}
+
+/** A day's number in the grid: "29", or "9" for 9 Ordibehesht. */
+export function dayNumberLabel(date: Date, system: CalendarSystem): string {
+ return String(system.dayOfMonth(date));
+}
+
+/**
+ * The label of a month's first day in a grid that runs through months:
+ * "Oct" or "Oct 1"; the Persian month name, with "1" before it, for Jalali.
+ */
+export function monthStartLabel(date: Date, system: CalendarSystem, locale: Locale, withDay: boolean): string {
+ if (!system.jalali) return format(date, withDay ? 'MMM d' : 'MMM', { locale });
+ const name = system.monthName(date, 'long', locale);
+ return withDay ? `${system.dayOfMonth(date)} ${name}` : name;
+}
+
+/** The calendar system of a date-fns locale: Jalali for Persian. */
+export function calendarSystemOfLocale(locale: Locale): CalendarSystem {
+ return calendarSystemFor(locale.code?.split('-')[0]);
+}
+
+/**
+ * A calendar date in one of formatCalendarDate's styles. For Jalali, the
+ * Persian weekday and month with the Jalali day and year, as the webmail's
+ * useFormatEventDate writes them ("چهارشنبه, 9 اردیبهشت 1405").
+ */
+export function formatCalendarDay(date: Date, locale: Locale, style: CalendarDateStyle): string {
+ const system = calendarSystemOfLocale(locale);
+ if (!system.jalali) return formatCalendarDate(date, locale.code, style);
+ const { jy, jm, jd } = toJalali(date);
+ const month = JALALI_MONTHS[jm - 1];
+ switch (style) {
+ case 'longWeekdayMonthDayYear':
+ return `${format(date, 'EEEE', { locale })}, ${jd} ${month} ${jy}`;
+ case 'weekdayMonthDay':
+ return `${format(date, 'EEEE', { locale })}, ${jd} ${month}`;
+ case 'monthDay':
+ return `${jd} ${month}`;
+ case 'monthDayYear':
+ return `${jd} ${month} ${jy}`;
+ }
+}
diff --git a/src/lib/jalali.ts b/src/lib/jalali.ts
new file mode 100644
index 00000000..cdd5d5d8
--- /dev/null
+++ b/src/lib/jalali.ts
@@ -0,0 +1,137 @@
+// Jalali (Persian, Solar Hijri) calendar arithmetic. The calendar keeps every
+// date as a Gregorian Date; these functions convert at the display layer only,
+// as the webmail's lib/jalali-utils.ts does.
+//
+// The conversion is a port of jalaali-js (https://github.com/jalaali/jalaali-js,
+// MIT, Behrang Noruzi Niya), which the webmail uses as a dependency. The
+// algorithm is Kazimierz M. Borkowski's, valid for Jalali years -61 to 3177.
+
+/** A Jalali date: year (e.g. 1405), month 1 (Farvardin) to 12 (Esfand), day 1-31. */
+export interface JalaliDate {
+ jy: number;
+ jm: number;
+ jd: number;
+}
+
+const BREAKS = [
+ -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178,
+];
+
+function div(a: number, b: number): number {
+ return Math.trunc(a / b);
+}
+
+function mod(a: number, b: number): number {
+ return a - Math.trunc(a / b) * b;
+}
+
+/** Julian day number of a Gregorian date. */
+function g2d(gy: number, gm: number, gd: number): number {
+ let d = div((gy + div(gm - 8, 6) + 100100) * 1461, 4)
+ + div(153 * mod(gm + 9, 12) + 2, 5)
+ + gd - 34840408;
+ d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
+ return d;
+}
+
+/** Gregorian date of a Julian day number. */
+function d2g(jdn: number): { gy: number; gm: number; gd: number } {
+ let j = 4 * jdn + 139361631;
+ j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
+ const i = div(mod(j, 1461), 4) * 5 + 308;
+ const gd = div(mod(i, 153), 5) + 1;
+ const gm = mod(div(i, 153), 12) + 1;
+ const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
+ return { gy, gm, gd };
+}
+
+/**
+ * For Jalali year `jy`: the Gregorian year it starts in, the day of March
+ * that is 1 Farvardin, and how many years have passed since the last leap
+ * year (0 = this one is a leap year).
+ */
+function jalCal(jy: number): { leap: number; gy: number; march: number } {
+ const bl = BREAKS.length;
+ const gy = jy + 621;
+ let leapJ = -14;
+ let jp = BREAKS[0];
+ let jump = 0;
+ if (jy < jp || jy >= BREAKS[bl - 1]) throw new RangeError(`Invalid Jalali year ${jy}`);
+ for (let i = 1; i < bl; i += 1) {
+ const jm = BREAKS[i];
+ jump = jm - jp;
+ if (jy < jm) break;
+ leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
+ jp = jm;
+ }
+ let n = jy - jp;
+ leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
+ if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
+ const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
+ const march = 20 + leapJ - leapG;
+ if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
+ let leap = mod(mod(n + 1, 33) - 1, 4);
+ if (leap === -1) leap = 4;
+ return { leap, gy, march };
+}
+
+function j2d(jy: number, jm: number, jd: number): number {
+ const r = jalCal(jy);
+ return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
+}
+
+function d2j(jdn: number): JalaliDate {
+ const { gy } = d2g(jdn);
+ let jy = gy - 621;
+ const r = jalCal(jy);
+ const jdn1f = g2d(gy, 3, r.march);
+ let k = jdn - jdn1f;
+ if (k >= 0) {
+ if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 };
+ k -= 186;
+ } else {
+ jy -= 1;
+ k += 179;
+ if (r.leap === 1) k += 1;
+ }
+ return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 };
+}
+
+/** Whether Jalali year `jy` has 366 days (Esfand 30). */
+export function isLeapJalaliYear(jy: number): boolean {
+ return jalCal(jy).leap === 0;
+}
+
+/** Days in a Jalali month. */
+export function jalaliMonthLength(jy: number, jm: number): number {
+ if (jm <= 6) return 31;
+ if (jm <= 11) return 30;
+ return isLeapJalaliYear(jy) ? 30 : 29;
+}
+
+/** The Jalali date of a Date's local calendar day. */
+export function toJalali(date: Date): JalaliDate {
+ return d2j(g2d(date.getFullYear(), date.getMonth() + 1, date.getDate()));
+}
+
+/** Local midnight of a Jalali date. */
+export function fromJalali(jy: number, jm: number, jd: number): Date {
+ const { gy, gm, gd } = d2g(j2d(jy, jm, jd));
+ return new Date(gy, gm - 1, gd);
+}
+
+/** Persian month names, Farvardin to Esfand (webmail JALALI_MONTHS). */
+export const JALALI_MONTHS: readonly string[] = [
+ 'فروردین',
+ 'اردیبهشت',
+ 'خرداد',
+ 'تیر',
+ 'مرداد',
+ 'شهریور',
+ 'مهر',
+ 'آبان',
+ 'آذر',
+ 'دی',
+ 'بهمن',
+ 'اسفند',
+];
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index 7b6a5177..7ba8289e 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -29,16 +29,14 @@ import {
Square,
} from '../components/icons';
import {
- format,
addDays,
subDays,
- addMonths,
- subMonths,
addWeeks,
subWeeks,
type Locale,
} from 'date-fns';
import { useCalendarLocale } from '../lib/calendar-locale';
+import { dayNumberLabel, monthTitle, type CalendarSystem } from '../lib/calendar-system';
import { displayNow } from '../lib/calendar-timezone';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -153,10 +151,11 @@ type PendingAction =
// the next arrow press or edge usually finds them there already.
const RANGE_MARGIN_DAYS = 14;
-// The month on screen; the year only when it isn't this year.
-function headerTitle(date: Date, locale: Locale): string {
- const sameYear = date.getFullYear() === displayNow().getFullYear();
- return format(date, sameYear ? 'LLLL' : 'LLLL yyyy', { locale });
+// The month on screen; the year only when it isn't this year. Jalali
+// months and years in Persian, as the webmail's toolbar shows them.
+export function headerTitle(date: Date, locale: Locale, system: CalendarSystem): string {
+ const sameYear = system.year(date) === system.year(displayNow());
+ return monthTitle(date, system, locale, !sameYear);
}
export default function CalendarScreen() {
@@ -165,7 +164,7 @@ export default function CalendarScreen() {
const t = useLocaleStore((s) => s.t);
const currentUserEmails = useUserCalendarAddresses();
const resolveContactName = useContactNameResolver();
- const { locale } = useCalendarLocale();
+ const { locale, system } = useCalendarLocale();
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar);
@@ -197,8 +196,8 @@ export default function CalendarScreen() {
// shows exactly one period. Navigation moves the focus and, when that
// leaves the window, starts a fresh window there.
const windowOptions = React.useMemo(
- () => ({ weekStartsOn: calendarFirstDayOfWeek }),
- [calendarFirstDayOfWeek],
+ () => ({ weekStartsOn: calendarFirstDayOfWeek, calendarSystem: system }),
+ [calendarFirstDayOfWeek, system],
);
const [focus, setFocus] = React.useState(() => ({ date: displayNow(), nonce: 0 }));
// The day the scrolled view shows at its top, while it differs from the focus.
@@ -501,9 +500,9 @@ export default function CalendarScreen() {
viewMode === 'week' ? subWeeks(base, 1)
: viewMode === '3day' ? subDays(base, multiDays)
: viewMode === 'day' ? subDays(base, 1)
- : subMonths(base, 1),
+ : system.addMonths(base, -1),
);
- }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]);
+ }, [viewMode, visibleDate, focus.date, jumpTo, multiDays, system]);
const goNext = React.useCallback(() => {
const base = visibleDate ?? focus.date;
@@ -511,9 +510,9 @@ export default function CalendarScreen() {
viewMode === 'week' ? addWeeks(base, 1)
: viewMode === '3day' ? addDays(base, multiDays)
: viewMode === 'day' ? addDays(base, 1)
- : addMonths(base, 1),
+ : system.addMonths(base, 1),
);
- }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]);
+ }, [viewMode, visibleDate, focus.date, jumpTo, multiDays, system]);
const goToday = React.useCallback(() => {
jumpTo(displayNow());
@@ -1038,11 +1037,11 @@ export default function CalendarScreen() {
style={styles.titleBtn}
hitSlop={6}
accessibilityRole="button"
- accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale), t)}
+ accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale, system), t)}
accessibilityState={{ expanded: monthPanelOpen }}
>
- {headerTitle(titleDate, locale)}
+ {headerTitle(titleDate, locale, system)}
@@ -1095,7 +1094,7 @@ export default function CalendarScreen() {
accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')}
>
- {format(displayNow(), 'd')}
+ {dayNumberLabel(displayNow(), system)}
{enableCalendarTasks && (