From e6d47b6a16a67e359c9f8e0390c633293f22a218 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:15:50 +0200 Subject: [PATCH] feat: shared calendars, custom recurrence editor, and file sharing --- src/api/__tests__/calendar.test.ts | 36 +- src/api/__tests__/files.test.ts | 252 +++++++++++ src/api/blob.ts | 5 +- src/api/calendar.ts | 120 ++++- src/api/files.ts | 351 +++++++++------ src/api/types.ts | 54 +++ .../calendar/CalendarSidebarDrawer.tsx | 196 ++++++-- src/components/calendar/EventModal.tsx | 80 +++- src/components/calendar/RecurrenceEditor.tsx | 370 +++++++++++++++ src/components/files/ShareSheet.tsx | 425 ++++++++++++++++++ .../__tests__/recurrence-expansion.test.ts | 74 +++ src/lib/calendar-utils.ts | 53 ++- src/lib/email-export.ts | 8 +- src/lib/recurrence-expansion.ts | 218 +++++++-- src/lib/recurrence.ts | 246 ++++++++++ src/screens/CalendarScreen.tsx | 54 ++- src/screens/FilesScreen.tsx | 274 +++++++---- src/stores/__tests__/calendar-store.test.ts | 36 +- src/stores/calendar-store.ts | 81 +++- src/stores/settings-store.ts | 20 + 20 files changed, 2615 insertions(+), 338 deletions(-) create mode 100644 src/api/__tests__/files.test.ts create mode 100644 src/components/calendar/RecurrenceEditor.tsx create mode 100644 src/components/files/ShareSheet.tsx create mode 100644 src/lib/recurrence.ts diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 4655b51e..62cb31ce 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -42,10 +42,10 @@ describe('calendar operations', () => { }); describe('queryEvents', () => { - it('should send a CalendarEvent/query without server-side filter', async () => { - // Stalwart rejects inCalendars/after/before, so we fetch unfiltered and - // filter client-side. Asserting on the absence of `filter` keeps that - // contract pinned. + it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => { + // Stalwart rejects the draft's plural `inCalendars` filter (and + // after/before); calendars are restricted via singular `inCalendar` + // conditions and date filtering stays client-side. mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']], }); @@ -55,9 +55,35 @@ describe('calendar operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[0]).toBe('CalendarEvent/query'); - expect(call[1].filter).toBeUndefined(); + expect(call[1].filter).toEqual({ inCalendar: 'cal-1' }); expect(call[1].accountId).toBe('acc-1'); }); + + it('should OR multiple calendars and respect the target account', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], + }); + + await queryEvents(['cal-1', 'cal-2'], '', '', 'acc-shared'); + + const call = mockRequest.mock.calls[0][0][0]; + expect(call[1].filter).toEqual({ + operator: 'OR', + conditions: [{ inCalendar: 'cal-1' }, { inCalendar: 'cal-2' }], + }); + expect(call[1].accountId).toBe('acc-shared'); + }); + + it('should omit the filter when no calendars are given', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], + }); + + await queryEvents([], '', ''); + + const call = mockRequest.mock.calls[0][0][0]; + expect(call[1].filter).toBeUndefined(); + }); }); describe('getEvents', () => { diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts new file mode 100644 index 00000000..ad29521f --- /dev/null +++ b/src/api/__tests__/files.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + hasCapability: vi.fn(), + currentSession: null as unknown, + }, +})); + +vi.mock('../blob', () => ({ + getDownloadUrl: vi.fn( + (blobId: string, _name?: string, _type?: string, accountId?: string) => + `https://dl/${accountId ?? 'own'}/${blobId}`, + ), + uploadBlob: vi.fn(), + uploadBytes: vi.fn(), +})); + +import { jmapClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { + createFolder, + deleteFileNodes, + getAllFileNodesAcrossAccounts, + getFileNodeDownloadUrl, + getPrincipals, + isCrossAccountId, + isFolder, + setFileNodeShare, +} from '../files'; + +const mockRequest = jmapClient.request as ReturnType; +const mockHasCapability = jmapClient.hasCapability as ReturnType; + +function setSession(session: unknown) { + (jmapClient as { currentSession: unknown }).currentSession = session; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockHasCapability.mockImplementation( + (urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS, + ); + setSession({ + primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' }, + accounts: { + 'acc-1': { name: 'me@example.com', isPersonal: true }, + }, + }); +}); + +describe('isFolder', () => { + it('treats blob-less nodes as folders and anything with a blob as a file', () => { + // #379: a blob-marked node — even the legacy type:'d' dir markers — is a + // 0-byte file that nothing can be parented under. + expect(isFolder({ blobId: null })).toBe(true); + expect(isFolder({ blobId: undefined })).toBe(true); + expect(isFolder({ blobId: 'blob-1' })).toBe(false); + }); +}); + +describe('createFolder', () => { + it('creates a real container node: no blobId/type/size, parentId set', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']], + }); + + await createFolder('Docs', 'parent-1'); + + const [method, args] = mockRequest.mock.calls[0][0][0]; + expect(method).toBe('FileNode/set'); + expect(args.create['new-dir']).toEqual({ name: 'Docs', parentId: 'parent-1' }); + }); + + it('omits parentId at the root', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']], + }); + + await createFolder('Docs', null); + + const args = mockRequest.mock.calls[0][0][0][1]; + expect(args.create['new-dir']).toEqual({ name: 'Docs' }); + }); +}); + +describe('deleteFileNodes', () => { + it('lets the server cascade into folder children', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/set', { destroyed: ['f1'] }, '0']], + }); + + await deleteFileNodes(['f1']); + + const args = mockRequest.mock.calls[0][0][0][1]; + expect(args.destroy).toEqual(['f1']); + expect(args.onDestroyRemoveChildren).toBe(true); + }); + + it('throws when the server refuses a destroy', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['FileNode/set', { notDestroyed: { f1: { description: 'forbidden' } } }, '0'], + ], + }); + + await expect(deleteFileNodes(['f1'])).rejects.toThrow('forbidden'); + }); +}); + +describe('setFileNodeShare', () => { + it('patches shareWith/{principalId} and requests the principals:owner capability', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']], + }); + const rights = { + mayRead: true, mayAddChildren: false, mayRename: false, + mayDelete: false, mayModifyContent: false, mayShare: false, + }; + + await setFileNodeShare('node-1', 'principal-2', rights); + + const [calls, using] = mockRequest.mock.calls[0]; + expect(calls[0][1].update['node-1']).toEqual({ 'shareWith/principal-2': rights }); + expect(using).toContain(CAPABILITIES.PRINCIPALS_OWNER); + }); + + it('sends null rights to revoke access', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']], + }); + + await setFileNodeShare('node-1', 'principal-2', null); + + const args = mockRequest.mock.calls[0][0][0][1]; + expect(args.update['node-1']).toEqual({ 'shareWith/principal-2': null }); + }); + + it('throws when the server rejects or does not confirm the update', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['FileNode/set', { notUpdated: { 'node-1': { description: 'no mayShare right' } } }, '0'], + ], + }); + await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow('no mayShare right'); + + mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', {}, '0']] }); + await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow( + 'Server did not confirm the share update', + ); + }); +}); + +describe('getAllFileNodesAcrossAccounts', () => { + it('namespaces ids of nodes from other principals and tags them isShared', async () => { + setSession({ + primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' }, + accounts: { + 'acc-1': { name: 'me@example.com', isPersonal: true }, + 'acc-2': { name: 'Team', isPersonal: false }, + }, + }); + mockRequest + .mockResolvedValueOnce({ + methodResponses: [ + ['FileNode/get', { list: [{ id: 'own-1', name: 'mine.txt', parentId: null, blobId: 'b1' }] }, '0'], + ], + }) + .mockResolvedValueOnce({ + methodResponses: [ + ['FileNode/get', { + list: [ + { id: 's1', name: 'Shared Folder', parentId: null, blobId: null }, + { id: 's2', name: 'inside.txt', parentId: 's1', blobId: 'b2' }, + ], + }, '0'], + ], + }); + + const nodes = await getAllFileNodesAcrossAccounts(); + + expect(nodes).toHaveLength(3); + const own = nodes.find((n) => n.id === 'own-1')!; + expect(own.isShared).toBe(false); + expect(isCrossAccountId(own.id)).toBe(false); + + const folder = nodes.find((n) => n.name === 'Shared Folder')!; + expect(folder.id).toBe('acc-2:s1'); + expect(folder.isShared).toBe(true); + expect(folder.accountId).toBe('acc-2'); + expect(folder.accountName).toBe('Team'); + + // parentId is namespaced the same way so child lookups keep working. + const child = nodes.find((n) => n.name === 'inside.txt')!; + expect(child.parentId).toBe('acc-2:s1'); + }); + + it('skips personal accounts without the filenode capability', async () => { + setSession({ + primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' }, + accounts: { + 'acc-1': { name: 'me@example.com', isPersonal: true }, + 'acc-3': { name: 'other personal', isPersonal: true }, + }, + }); + mockRequest.mockResolvedValue({ + methodResponses: [['FileNode/get', { list: [] }, '0']], + }); + + await getAllFileNodesAcrossAccounts(); + + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-1'); + }); +}); + +describe('getFileNodeDownloadUrl', () => { + it('routes shared blobs to the owning account', () => { + const url = getFileNodeDownloadUrl({ + id: 'acc-2:s2', name: 'inside.txt', type: 'text/plain', + blobId: 'b2', accountId: 'acc-2', + }); + expect(url).toBe('https://dl/acc-2/b2'); + }); +}); + +describe('getPrincipals', () => { + it('queries then gets via back-reference', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Principal/query', { ids: ['p1'] }, '0'], + ['Principal/get', { list: [{ id: 'p1', name: 'other', type: 'individual' }] }, '1'], + ], + }); + + const principals = await getPrincipals(); + + expect(principals).toEqual([{ id: 'p1', name: 'other', type: 'individual' }]); + const calls = mockRequest.mock.calls[0][0]; + expect(calls[0][0]).toBe('Principal/query'); + expect(calls[1][0]).toBe('Principal/get'); + expect(calls[1][1]['#ids']).toEqual({ resultOf: '0', name: 'Principal/query', path: '/ids' }); + }); + + it('returns empty when the server lacks the principals capability', async () => { + mockHasCapability.mockReturnValue(false); + expect(await getPrincipals()).toEqual([]); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/blob.ts b/src/api/blob.ts index 64f344b8..e0f8fa4b 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -116,12 +116,15 @@ export function getDownloadUrl( blobId: string, name?: string, type?: string, + // Blobs of nodes shared by another principal live in the owner's account; + // downloading them with our own accountId 404s. + accountId?: string, ): string { const session = jmapClient.currentSession; if (!session) throw new Error('Not connected'); return session.downloadUrl - .replace('{accountId}', encodeURIComponent(jmapClient.accountId)) + .replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId)) .replace('{blobId}', encodeURIComponent(blobId)) .replace('{name}', encodeURIComponent(name ?? 'download')) .replace('{type}', encodeURIComponent(type ?? 'application/octet-stream')); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index ae699b15..fbfa246f 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -4,6 +4,39 @@ import type { Calendar, CalendarEvent } from './types'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; +/** + * IANA time zone of the device, sent as the `timeZone` argument on + * CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime + * filter values and computes utcStart/utcEnd for floating events in this zone, + * defaulting to UTC when absent — which shifts range boundaries and + * floating-event times for any user not in UTC. Stalwart ignores unparseable + * values, so sending it is always safe. + */ +function getUserTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + +/** + * Build a CalendarEvent/query filter restricting results to the given + * calendars. Stalwart implements the singular `inCalendar` condition (one + * calendar id per condition), not the draft's plural `inCalendars` array — + * sending the plural form fails the whole query with `unsupportedFilter`. + * Multiple calendars are expressed as an OR of singular conditions. + */ +function buildInCalendarFilter(calendarIds: string[]): Record { + if (calendarIds.length === 1) { + return { inCalendar: calendarIds[0] }; + } + return { + operator: 'OR', + conditions: calendarIds.map((id) => ({ inCalendar: id })), + }; +} + const CALENDAR_EVENT_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', @@ -26,43 +59,81 @@ function methodResult(res: any, index = 0): T { return entry[1] as T; } +// Other session accounts that expose the calendars capability hold calendars +// shared with the user (JMAP surfaces shared data under the sharer's account). +function sharedCalendarAccountIds(): string[] { + const session = jmapClient.currentSession; + const primary = jmapClient.accountId; + return Object.entries(session?.accounts ?? {}) + .filter(([id, info]) => + id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS]) + .map(([id]) => id); +} + export async function getCalendars(): Promise { const accountId = jmapClient.accountId; const res = await jmapClient.request( [['Calendar/get', { accountId }, '0']], USING, ); - return methodResult<{ list: Calendar[] }>(res).list ?? []; + const own = methodResult<{ list: Calendar[] }>(res).list ?? []; + + // Calendars shared with the user live in other session accounts. Failures + // there (revoked share, transient error) must not hide the user's own + // calendars, so each shared account is fetched best-effort. + const shared = await Promise.all( + sharedCalendarAccountIds().map(async (sharedAccountId) => { + try { + const sharedRes = await jmapClient.request( + [['Calendar/get', { accountId: sharedAccountId }, '0']], + USING, + ); + const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? []; + return list.map((cal) => ({ ...cal, accountId: sharedAccountId, isShared: true })); + } catch { + return []; + } + }), + ); + return [...own, ...shared.flat()]; } export async function queryEvents( - _calendarIds: string[], + calendarIds: string[], _after: string, _before: string, + accountId?: string, ): Promise { - // Stalwart rejects inCalendars/after/before filters on CalendarEvent/query. - // Fetch all events in the account; calendar visibility + date filtering is - // done client-side. - const accountId = jmapClient.accountId; + // Stalwart rejects after/before filters on CalendarEvent/query; date + // filtering is done client-side. Calendars are restricted via the singular + // `inCalendar` condition (the plural `inCalendars` fails the whole query + // with unsupportedFilter on Stalwart). + const account = accountId || jmapClient.accountId; + const args: Record = { accountId: account, limit: 1000 }; + const timeZone = getUserTimeZone(); + if (timeZone) args.timeZone = timeZone; + if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds); const res = await jmapClient.request( - [['CalendarEvent/query', { accountId, limit: 1000 }, '0']], + [['CalendarEvent/query', args, '0']], USING, ); return methodResult<{ ids: string[] }>(res).ids ?? []; } -export async function getEvents(ids: string[]): Promise { +export async function getEvents(ids: string[], accountId?: string): Promise { if (ids.length === 0) return []; - const accountId = jmapClient.accountId; + const account = accountId || jmapClient.accountId; + const timeZone = getUserTimeZone(); const batchSize = jmapClient.getMaxObjectsInGet(); const all: CalendarEvent[] = []; for (let i = 0; i < ids.length; i += batchSize) { const batch = ids.slice(i, i + batchSize); const res = await jmapClient.request( [['CalendarEvent/get', { - accountId, + accountId: account, ids: batch, properties: CALENDAR_EVENT_PROPERTIES, + ...(timeZone ? { timeZone } : {}), }, '0']], USING, ); @@ -91,8 +162,9 @@ export async function createEvent( event: Partial, calendarId: string, sendSchedulingMessages?: boolean, + targetAccountId?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { create: { @@ -136,8 +208,9 @@ export async function updateEvent( id: string, changes: Partial | Record, sendSchedulingMessages?: boolean, + targetAccountId?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']], USING, @@ -150,8 +223,9 @@ export async function updateEvent( export async function deleteEvents( ids: string[], sendSchedulingMessages?: boolean, + targetAccountId?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = targetAccountId || jmapClient.accountId; await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']], USING, @@ -165,8 +239,9 @@ export async function rsvpEvent( participantId: string, status: 'accepted' | 'declined' | 'tentative', replyTo?: Record | null, + targetAccountId?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = targetAccountId || jmapClient.accountId; // Escape per RFC 6901: ~ → ~0, / → ~1. const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const patch: Record = { @@ -225,6 +300,23 @@ export async function createCalendar( return created; } +/** + * Mark a calendar as the account default. `isDefault` is read-only in + * Stalwart's Calendar/set — the default is changed via the + * `onSuccessSetIsDefault` request argument instead. + */ +export async function setDefaultCalendar( + calendarId: string, + targetAccountId?: string, +): Promise { + const accountId = targetAccountId || jmapClient.accountId; + const res = await jmapClient.request( + [['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']], + USING, + ); + methodResult(res); +} + // Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too — // Stalwart otherwise refuses to delete a non-empty calendar. export async function deleteCalendar(id: string): Promise { diff --git a/src/api/files.ts b/src/api/files.ts index 5c252905..fcda49ed 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,188 +1,277 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; -import type { FileNode } from './types'; -import { getDownloadUrl, uploadBlob, uploadBytes } from './blob'; +import type { FileNode, FileNodeRights, Principal } from './types'; +import { getDownloadUrl, uploadBlob } from './blob'; -const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES]; - -// Stalwart and other JMAP servers use one of these strings on FileNode.type to -// mark a directory; everything else is a MIME type. -const DIRECTORY_TYPES = new Set([ - 'd', - 'application/x-directory', - 'text/directory', - 'httpd/unix-directory', - 'inode/directory', -]); - -export function isDirectory(node: Pick): boolean { - const t = node.type; - if (!t) return false; - return DIRECTORY_TYPES.has(t) || t.includes('directory'); +// A FileNode is a folder (container) only when it has no blob content — the +// server stores it with `file == null`. Sending a blobId/type/size on create +// (as older builds did: type 'd' + an empty blob) makes a 0-byte FILE that +// nothing can ever be parented under (#379). The webmail migrates those +// legacy flat-named nodes into the real parentId hierarchy on load; this app +// reads and writes only the real hierarchy. +export function isFolder(node: Pick): boolean { + return node.blobId == null; } -// Stalwart rejects `/` in FileNode names, so the webmail (and now this app) -// uses U+2215 DIVISION SLASH as the path separator inside the flat `name` -// field. Hierarchy is encoded into the name — there is no `parentId` nesting. -// e.g. a file inside the `Documents` folder is stored with -// `name = "Documents∕report.pdf"`. The display name shown to the user -// strips the prefix. -export const PATH_SEP = '∕'; +// Stalwart omits shareWith/myRights from FileNode/get unless they are +// requested explicitly, so the share sheet and indicators must name them here. +const FILE_NODE_PROPERTIES = [ + 'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated', + 'shareWith', 'myRights', +]; -// Path is a list of folder display names from root. [] is root, -// ['Documents'] is inside Documents, ['Documents','sub'] is inside the -// `sub` folder under Documents. -export function pathToPrefix(path: readonly string[]): string { - if (path.length === 0) return ''; - return path.join(PATH_SEP) + PATH_SEP; +export function supportsSharing(): boolean { + return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS); } -// True when `node` is a direct child (not a descendant) of `prefix`. -export function isDirectChildOfPrefix(node: Pick, prefix: string): boolean { - if (!node.name) return false; - if (prefix === '') return !node.name.includes(PATH_SEP); - if (!node.name.startsWith(prefix)) return false; - const rest = node.name.slice(prefix.length); - return rest.length > 0 && !rest.includes(PATH_SEP); +function filesAccountId(): string { + return ( + jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ?? + jmapClient.accountId + ); } -export function nodeDisplayName(node: Pick, prefix: string): string { - return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name; +function fileUsing(): string[] { + const using: string[] = [CAPABILITIES.CORE]; + if (jmapClient.hasCapability(CAPABILITIES.FILES)) { + using.push(CAPABILITIES.FILES); + } + // Required for shareWith/myRights on FileNode and for cross-account + // (shared-with-me) FileNode/get. + if (supportsSharing()) { + using.push(CAPABILITIES.PRINCIPALS_OWNER); + } + return using; } -// Stalwart's FileNode/get does not support a parentId filter and some builds -// omit `type` from the default property set. We fetch the whole tree and -// filter client-side; properties are listed explicitly so `isDirectory()` -// always has a value to look at. -const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated']; +// Nodes fetched from another principal's account are namespaced +// "accountId:nodeId" so they can't collide with the primary account's ids. +// JMAP ids never contain ':', so the separator unambiguously marks a shared +// (cross-account) node. +export function isCrossAccountId(id: string | null | undefined): boolean { + return id != null && id.includes(':'); +} +// Fetch every FileNode in the account, files AND folders, to build the +// hierarchy client-side from parentId links. +// +// IMPORTANT: this uses FileNode/get with ids:null (return-all), NOT +// FileNode/query — Stalwart's query only returns leaf files and omits folder +// nodes entirely, which would make every folder invisible. export async function getAllFileNodes(): Promise { - const accountId = jmapClient.accountId; + const accountId = filesAccountId(); const res = await jmapClient.request( [['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']], - USING, + fileUsing(), ); - return (res.methodResponses[0][1].list ?? []) as FileNode[]; + const result = res.methodResponses[0]; + if (!result || result[0] === 'error') { + throw new Error(result?.[1]?.description || 'FileNode list failed'); + } + return (result[1].list ?? []) as FileNode[]; } -export async function createFileNode( - fileNode: Partial, -): Promise { - const accountId = jmapClient.accountId; - const res = await jmapClient.request( - [['FileNode/set', { - accountId, - create: { 'new-file': fileNode }, - }, '0']], - USING, - ); - const created = res.methodResponses[0][1].created?.['new-file']; - if (!created) { - const err = res.methodResponses[0][1].notCreated?.['new-file']; - throw new Error(err?.description || 'Create failed'); +// Accounts (primary + shared/group) that can hold FileNodes: any non-primary +// account that advertises the filenode capability or is non-personal, since +// Stalwart doesn't always advertise capabilities on shared accounts. +function filesCapableAccountIds(): string[] { + const primaryId = filesAccountId(); + const accounts = jmapClient.currentSession?.accounts ?? {}; + const rest = Object.entries(accounts) + .filter(([id, info]) => + id !== primaryId && + (info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal)) + .map(([id]) => id); + return [primaryId, ...rest]; +} + +// Fetch every FileNode the logged-in user can see across all accessible +// accounts. Nodes owned by another principal (shared with the user) are +// tagged `isShared: true` with the owning accountId/accountName, and their +// ids/parentIds are namespaced "accountId:nodeId". Mirrors the webmail's +// listAllFileNodesAcrossAccounts. +export async function getAllFileNodesAcrossAccounts(): Promise { + const primaryId = filesAccountId(); + const accounts = jmapClient.currentSession?.accounts ?? {}; + const all: FileNode[] = []; + + for (const accountId of filesCapableAccountIds()) { + const isPrimary = accountId === primaryId; + try { + const res = await jmapClient.request( + [['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']], + fileUsing(), + ); + const result = res.methodResponses[0]; + if (!result || result[0] === 'error') continue; + for (const node of (result[1].list ?? []) as FileNode[]) { + all.push({ + ...node, + id: isPrimary ? node.id : `${accountId}:${node.id}`, + parentId: node.parentId == null + ? null + : (isPrimary ? node.parentId : `${accountId}:${node.parentId}`), + accountId, + accountName: accounts[accountId]?.name || accountId, + isShared: !isPrimary, + }); + } + } catch { + // A single unreachable shared account shouldn't hide the user's own + // files; skip it and keep aggregating. + } } - return created; + + return all; } export async function createFolder( - displayName: string, - currentPath: readonly string[], + name: string, + parentId: string | null, ): Promise { - // Stalwart's FileNode/set rejects directory creates without a blobId - // ("Missing blob id"), so we upload an empty blob first and reference it. - // Mirrors the webmail's createFileDirectory. - const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory'); - const fullName = pathToPrefix(currentPath) + displayName; - return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 }); + const accountId = filesAccountId(); + const props: Record = { name }; + if (parentId !== null) props.parentId = parentId; + + const res = await jmapClient.request( + [['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']], + fileUsing(), + ); + const result = res.methodResponses[0][1]; + const created = result.created?.['new-dir']; + if (!created) { + const err = result.notCreated?.['new-dir']; + throw new Error(err?.description || 'Create folder failed'); + } + return { ...props, ...created } as FileNode; } export async function updateFileNode( id: string, updates: Partial>, ): Promise { - const accountId = jmapClient.accountId; + const accountId = filesAccountId(); const res = await jmapClient.request( [['FileNode/set', { accountId, update: { [id]: updates } }, '0']], - USING, + fileUsing(), ); const notUpdated = res.methodResponses[0][1].notUpdated?.[id]; if (notUpdated) throw new Error(notUpdated.description || 'Update failed'); } -// Rename a node and, if it's a folder, cascade the rename to every descendant -// so the encoded path stays consistent. The webmail does this same dance. -export async function renameFileNode( - node: FileNode, - newDisplayName: string, - currentPath: readonly string[], - allNodes: readonly FileNode[], -): Promise { - const prefix = pathToPrefix(currentPath); - const oldServerName = node.name; - const newServerName = prefix + newDisplayName; - if (oldServerName === newServerName) return; - - await updateFileNode(node.id, { name: newServerName }); - - if (isDirectory(node)) { - const oldFolderPrefix = oldServerName + PATH_SEP; - const newFolderPrefix = newServerName + PATH_SEP; - for (const n of allNodes) { - if (n.id === node.id) continue; - if (n.name.startsWith(oldFolderPrefix)) { - await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) }); - } - } - } +export async function renameFileNode(id: string, newName: string): Promise { + await updateFileNode(id, { name: newName }); } export async function deleteFileNodes(ids: string[]): Promise { if (ids.length === 0) return; - const accountId = jmapClient.accountId; - await jmapClient.request( - [['FileNode/set', { accountId, destroy: ids }, '0']], - USING, + const accountId = filesAccountId(); + const res = await jmapClient.request( + [['FileNode/set', { + accountId, + destroy: ids, + // The server removes descendant nodes of destroyed folders. + onDestroyRemoveChildren: true, + }, '0']], + fileUsing(), ); -} - -// Delete `targets` and every descendant. Stalwart doesn't enforce parent -// references, so we collect descendants by name-prefix and destroy them all -// in one call. Without this, deleting a folder leaves its files orphaned and -// still discoverable at the old encoded path. -export async function deleteWithDescendants( - targets: readonly FileNode[], - allNodes: readonly FileNode[], -): Promise { - const ids = new Set(); - for (const node of targets) { - ids.add(node.id); - if (isDirectory(node)) { - const folderPrefix = node.name + PATH_SEP; - for (const n of allNodes) { - if (n.name.startsWith(folderPrefix)) ids.add(n.id); - } - } + const notDestroyed = res.methodResponses[0][1].notDestroyed as + | Record + | undefined; + const failedIds = Object.keys(notDestroyed ?? {}); + if (failedIds.length > 0) { + throw new Error( + notDestroyed![failedIds[0]]?.description || `Failed to delete ${failedIds.length} item(s)`, + ); } - await deleteFileNodes(Array.from(ids)); } export function getFileNodeDownloadUrl(node: FileNode): string { if (!node.blobId) throw new Error('Folders cannot be downloaded'); - return getDownloadUrl(node.blobId, node.name, node.type); + return getDownloadUrl(node.blobId, node.name, node.type, node.accountId); } export async function uploadFileNode( uri: string, - displayName: string, + name: string, mimeType: string, - currentPath: readonly string[], + parentId: string | null, ): Promise { + const accountId = filesAccountId(); const blob = await uploadBlob(uri, mimeType); - const fullName = pathToPrefix(currentPath) + displayName; - return createFileNode({ - name: fullName, - type: blob.type || mimeType || 'application/octet-stream', + const type = blob.type || mimeType || 'application/octet-stream'; + const props: Record = { + name, + // Stalwart caps the stored MIME type; very long types fail the create. + type: type.length > 30 ? 'application/octet-stream' : type, blobId: blob.blobId, size: blob.size, - }); + }; + if (parentId !== null) props.parentId = parentId; + + const res = await jmapClient.request( + [['FileNode/set', { accountId, create: { 'new-file': props } }, '0']], + fileUsing(), + ); + const result = res.methodResponses[0][1]; + const created = result.created?.['new-file']; + if (!created) { + const err = result.notCreated?.['new-file']; + throw new Error(err?.description || 'Upload failed'); + } + return { ...props, ...created } as FileNode; +} + +// ── Sharing (RFC 9670) ──────────────────────────────────── + +// Add, update, or remove a principal's rights on an owned FileNode (file or +// folder). Pass rights: null to revoke. Stalwart applies it via a +// `shareWith/{principalId}` patch on FileNode/set. Sharing a folder shares +// its whole subtree — which is why this app must store real parentId +// hierarchy rather than the legacy flat-name encoding. +export async function setFileNodeShare( + fileNodeId: string, + principalId: string, + rights: FileNodeRights | null, +): Promise { + const accountId = filesAccountId(); + const res = await jmapClient.request( + [['FileNode/set', { + accountId, + update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } }, + }, '0']], + fileUsing(), + ); + const result = res.methodResponses[0][1]; + if (result.notUpdated?.[fileNodeId]) { + throw new Error(result.notUpdated[fileNodeId].description || 'Failed to update file share'); + } + if (!result.updated || !(fileNodeId in result.updated)) { + throw new Error('Server did not confirm the share update'); + } +} + +// List all principals visible to the user. Stalwart returns the full +// directory regardless of `filter`, so callers filter client-side. +export async function getPrincipals(): Promise { + if (!supportsSharing()) return []; + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [ + ['Principal/query', { accountId }, '0'], + ['Principal/get', { + accountId, + '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' }, + }, '1'], + ], + [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS], + ); + const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get'); + return (getResp?.[1].list ?? []) as Principal[]; +} + +// The principal id that represents the logged-in user (excluded from the +// share picker — sharing with yourself is a no-op the server may reject). +export function ownPrincipalId(): string { + return filesAccountId(); } diff --git a/src/api/types.ts b/src/api/types.ts index f6324711..3d2e4be3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -405,6 +405,7 @@ export interface VirtualLocation { } export interface RecurrenceRule { + '@type'?: 'RecurrenceRule'; frequency: string; interval?: number; until?: string; @@ -419,6 +420,8 @@ export interface RecurrenceRule { bySecond?: number[]; bySetPosition?: number[]; firstDayOfWeek?: string; + rscale?: string; + skip?: string; } export interface Alert { @@ -467,6 +470,10 @@ export interface CalendarEvent { color?: string; // Client-only: account the event/occurrence belongs to (for shared/virtual). localAccountId?: string; + // Client-only: JMAP account the event was fetched from. Absent for the + // primary account; set for events on calendars shared with the user so + // mutations can be routed to the owning account. + accountId?: string; } export interface CalendarRights { @@ -492,6 +499,17 @@ export interface Calendar { isDefault?: boolean; sortOrder?: number; myRights?: CalendarRights; + // Client-only: JMAP account the calendar was fetched from. Absent for the + // primary account; set for calendars shared with the user. + accountId?: string; + // Client-only: true when the calendar lives in another account (shared + // with the user by its owner). + isShared?: boolean; + // Client-only: set when `color` has been replaced by the viewer's local + // override for a shared calendar (see lib/calendar-utils). When true, the + // override wins over per-event colors so the whole shared calendar paints + // uniformly. + colorIsLocalOverride?: boolean; } // ─── Files (FileNode) ─────────────────────────────────── @@ -505,6 +523,40 @@ export interface FileNode { size?: number; created?: string; updated?: string; + // JMAP Sharing (RFC 9670). Populated only when the server advertises the + // principals capability and the properties are requested explicitly. A node + // is shared-out when `shareWith` has entries; `myRights` describes what the + // viewer may do (always full rights on owned nodes). + myRights?: FileNodeRights; + shareWith?: Record | null; + // True when this node was fetched from another principal's account that was + // shared with the logged-in user ("Shared with me"). + isShared?: boolean; + // Owning account's JMAP id and display name, set when aggregating nodes + // across accounts so blob downloads route to the right account. + accountId?: string; + accountName?: string; +} + +// FileNode rights as defined by Stalwart's JmapSharedObject implementation. +export interface FileNodeRights { + mayRead: boolean; + mayAddChildren: boolean; + mayRename: boolean; + mayDelete: boolean; + mayModifyContent: boolean; + mayShare: boolean; +} + +// RFC 9670 principal — an entity (user, group, resource) that files and +// calendars can be shared with. On Stalwart the principal id doubles as the +// account id of that user/group. +export interface Principal { + id: string; + type: 'individual' | 'group' | 'resource' | 'location' | 'other'; + name: string; + description?: string | null; + email?: string | null; } // ─── Push / SSE ───────────────────────────────────────── @@ -538,4 +590,6 @@ export const CAPABILITIES = { SIEVE: 'urn:ietf:params:jmap:sieve', QUOTA: 'urn:ietf:params:jmap:quota', FILES: 'urn:ietf:params:jmap:filenode', + PRINCIPALS: 'urn:ietf:params:jmap:principals', + PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner', } as const; diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index 72ed0ca3..a9d04fb4 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -11,12 +11,13 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { Check, X, Upload, Rss } from 'lucide-react-native'; +import { Check, X, Upload, Rss, Shuffle, Star } from 'lucide-react-native'; import type { Calendar } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useAnimDuration } from '../../theme/dynamic'; -import { getCalendarColor } from '../../lib/calendar-utils'; +import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils'; +import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar'; interface CalendarSidebarDrawerProps { visible: boolean; @@ -26,6 +27,11 @@ interface CalendarSidebarDrawerProps { onClose: () => void; onImport?: () => void; onManageSubscriptions?: () => void; + // Long-press actions. Set-default applies to the user's own calendars; + // color change/reset applies to shared calendars (per-viewer recolor). + onSetDefault?: (calendar: Calendar) => void; + onSetColor?: (calendar: Calendar, color: string) => void; + onResetColor?: (calendar: Calendar) => void; } export function CalendarSidebarDrawer({ @@ -36,6 +42,9 @@ export function CalendarSidebarDrawer({ onClose, onImport, onManageSubscriptions, + onSetDefault, + onSetColor, + onResetColor, }: CalendarSidebarDrawerProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -43,6 +52,7 @@ export function CalendarSidebarDrawer({ const overlayOpacity = React.useRef(new Animated.Value(0)).current; const openDuration = useAnimDuration(240); const closeDuration = useAnimDuration(200); + const [expandedId, setExpandedId] = React.useState(null); React.useEffect(() => { if (visible) { @@ -60,6 +70,7 @@ export function CalendarSidebarDrawer({ }), ]).start(); } else { + setExpandedId(null); Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, @@ -78,8 +89,23 @@ export function CalendarSidebarDrawer({ const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]); - const myCalendars = calendars.filter((c) => !c.myRights || c.myRights.mayWrite !== false); - const subscribed = calendars.filter((c) => c.myRights && c.myRights.mayWrite === false); + const sharedCalendars = calendars.filter((cal) => cal.isShared); + const myCalendars = calendars.filter( + (cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false), + ); + const subscribed = calendars.filter( + (cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false, + ); + + const sectionProps = { + hiddenSet, + onToggle, + expandedId, + onExpand: setExpandedId, + onSetDefault, + onSetColor, + onResetColor, + }; return ( 0 && ( -
+
+ )} + + {sharedCalendars.length > 0 && ( +
)} {subscribed.length > 0 && ( -
+
)} {(onImport || onManageSubscriptions) && ( @@ -161,11 +181,21 @@ function Section({ calendars, hiddenSet, onToggle, + expandedId, + onExpand, + onSetDefault, + onSetColor, + onResetColor, }: { title: string; calendars: Calendar[]; hiddenSet: Set; onToggle: (id: string) => void; + expandedId: string | null; + onExpand: (id: string | null) => void; + onSetDefault?: (calendar: Calendar) => void; + onSetColor?: (calendar: Calendar, color: string) => void; + onResetColor?: (calendar: Calendar) => void; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -174,30 +204,85 @@ function Section({ {title} {calendars.map((cal) => { const visible = !hiddenSet.has(cal.id); + const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID; + const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault; + const canRecolor = !!onSetColor && !!cal.isShared; + const hasActions = canSetDefault || canRecolor; + const expanded = expandedId === cal.id && hasActions; return ( - onToggle(cal.id)} - style={({ pressed }) => [ - styles.row, - pressed && styles.rowPressed, - ]} - > - + onToggle(cal.id)} + onLongPress={hasActions ? () => onExpand(expanded ? null : cal.id) : undefined} + style={({ pressed }) => [ + styles.row, + pressed && styles.rowPressed, ]} > - {visible && } - - - {cal.name} - - + + {visible && } + + + {cal.name} + + {cal.isDefault && !cal.isShared && ( + + )} + + + {expanded && ( + + {canSetDefault && ( + { onExpand(null); onSetDefault!(cal); }} + style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} + > + + Set as default calendar + + )} + {canRecolor && ( + <> + + {CALENDAR_COLOR_PALETTE.map((color) => { + const active = cal.color?.toLowerCase() === color.toLowerCase(); + return ( + { onExpand(null); onSetColor!(cal, color); }} + style={[ + styles.paletteSwatch, + { backgroundColor: color }, + active && styles.paletteSwatchActive, + ]} + > + {active && } + + ); + })} + + {!!onResetColor && cal.colorIsLocalOverride && ( + { onExpand(null); onResetColor(cal); }} + style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} + > + + Random color + + )} + + )} + + )} + ); })} @@ -279,6 +364,43 @@ function makeStyles(c: ThemePalette) { rowName: { flex: 1, ...typography.body, color: c.text }, rowNameMuted: { color: c.textMuted }, + actionsPanel: { + marginHorizontal: spacing.lg, + marginBottom: spacing.sm, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.surface, + overflow: 'hidden', + }, + panelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + minHeight: 40, + }, + panelRowText: { ...typography.body, color: c.text }, + paletteRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + paletteSwatch: { + width: 24, + height: 24, + borderRadius: radius.full, + alignItems: 'center', + justifyContent: 'center', + }, + paletteSwatchActive: { + borderWidth: 2, + borderColor: c.text, + }, + actionsSection: { marginTop: spacing.lg, paddingTop: spacing.sm, diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index a4fa44b9..23fd78f4 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -31,6 +31,7 @@ import type { CalendarEvent, Participant, EventLocation, + RecurrenceRule, VirtualLocation, } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; @@ -49,10 +50,12 @@ import { REMINDER_PRESETS, type Reminder, } from '../../lib/calendar-alerts'; +import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; +import { RecurrenceEditor } from './RecurrenceEditor'; -type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; +type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [ { value: 'none', label: 'Does not repeat' }, @@ -60,6 +63,7 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [ { value: 'weekly', label: 'Weekly' }, { value: 'monthly', label: 'Monthly' }, { value: 'yearly', label: 'Yearly' }, + { value: 'custom', label: 'Custom…' }, ]; interface EventModalProps { @@ -99,11 +103,15 @@ function buildDuration(start: Date, end: Date): string { function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOption { const rule = event?.recurrenceRules?.[0]; if (!rule) return 'none'; - const f = rule.frequency.toLowerCase(); - if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') { - return f as RecurrenceOption; + if (isSimpleRecurrenceRule(rule)) { + const f = rule.frequency.toLowerCase(); + if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') { + return f as RecurrenceOption; + } } - return 'none'; + // Anything the simple presets can't represent is edited via the custom + // recurrence editor. + return 'custom'; } function detectLocation(event: CalendarEvent | null | undefined): string { @@ -156,9 +164,18 @@ export function EventModal({ const [allDay, setAllDay] = React.useState(false); const [start, setStart] = React.useState(() => nextHalfHour(defaultDate ?? new Date())); const [end, setEnd] = React.useState(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1)); - const [calendarId, setCalendarId] = React.useState(defaultCalendarId || calendars[0]?.id || ''); + // New events land in the explicitly requested calendar, else the account's + // default calendar, else the first one. + const fallbackCalendarId = + defaultCalendarId + || calendars.find((cal) => cal.isDefault && !cal.isShared)?.id + || calendars[0]?.id + || ''; + const [calendarId, setCalendarId] = React.useState(fallbackCalendarId); const [participants, setParticipants] = React.useState>({}); const [recurrence, setRecurrence] = React.useState('none'); + const [customRule, setCustomRule] = React.useState(null); + const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false); const [reminders, setReminders] = React.useState([]); const [location, setLocation] = React.useState(''); const [videoUrl, setVideoUrl] = React.useState(''); @@ -181,7 +198,10 @@ export function EventModal({ setEnd(getEventEndDate(event)); setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || ''); setParticipants(event.participants || {}); - setRecurrence(detectRecurrence(event)); + const detected = detectRecurrence(event); + setRecurrence(detected); + setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null); + setRecurrenceEditorOpen(false); setReminders(alertsToReminders(event.alerts)); setLocation(detectLocation(event)); setVideoUrl(detectVideoUrl(event)); @@ -192,9 +212,16 @@ export function EventModal({ setAllDay(false); setStart(d); setEnd(addHours(d, 1)); - setCalendarId(defaultCalendarId || calendars[0]?.id || ''); + setCalendarId( + defaultCalendarId + || calendars.find((cal) => cal.isDefault && !cal.isShared)?.id + || calendars[0]?.id + || '', + ); setParticipants({}); setRecurrence('none'); + setCustomRule(null); + setRecurrenceEditorOpen(false); setReminders([]); setLocation(''); setVideoUrl(''); @@ -241,7 +268,12 @@ export function EventModal({ : format(start, "yyyy-MM-dd'T'HH:mm:ss"), duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end), participants: Object.keys(participants).length > 0 ? participants : undefined, - recurrenceRules: recurrenceFromOption(recurrence), + recurrenceRules: + recurrence === 'custom' + ? customRule + ? [customRule] + : undefined + : recurrenceFromOption(recurrence), alerts: remindersToAlerts(reminders), useDefaultAlerts: reminders.length > 0 ? false : undefined, locations: buildLocations(location), @@ -259,7 +291,10 @@ export function EventModal({ }; const selectedCalendar = calendars.find((c) => c.id === calendarId); - const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None'; + const recurrenceLabel = + recurrence === 'custom' + ? (customRule && buildRecurrenceSummary(customRule)) || 'Custom' + : RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None'; const addReminder = (minutesBefore: number) => { setReminderPickerOpen(false); @@ -431,8 +466,14 @@ export function EventModal({ opt.value === recurrence && styles.popoverRowActive, ]} onPress={() => { - setRecurrence(opt.value); setRecurrenceOpen(false); + if (opt.value === 'custom') { + setRecurrenceEditorOpen(true); + return; + } + setRecurrence(opt.value); + setCustomRule(null); + setRecurrenceEditorOpen(false); }} > {opt.label} @@ -440,6 +481,23 @@ export function EventModal({ ))} )} + {recurrenceEditorOpen && ( + { + setCustomRule(rule); + setRecurrence('custom'); + setRecurrenceEditorOpen(false); + }} + onCancel={() => setRecurrenceEditorOpen(false)} + /> + )} + {recurrence === 'custom' && customRule && !recurrenceEditorOpen && ( + setRecurrenceEditorOpen(true)}> + Edit custom recurrence + + )}
} label="Reminders"> diff --git a/src/components/calendar/RecurrenceEditor.tsx b/src/components/calendar/RecurrenceEditor.tsx new file mode 100644 index 00000000..946622d2 --- /dev/null +++ b/src/components/calendar/RecurrenceEditor.tsx @@ -0,0 +1,370 @@ +import React from 'react'; +import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import { ChevronDown } from 'lucide-react-native'; +import { format, parseISO } from 'date-fns'; +import type { RecurrenceRule } from '../../api/types'; +import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { Button } from '..'; +import { + EDITOR_FREQUENCIES, + UNIT_LABELS, + WEEKDAYS, + buildRecurrenceSummary, + buildRuleFromEditorValue, + capitalize, + editorValueFromRule, + monthName, + nthLabel, + weekdayName, + type EditorFrequency, + type EndsMode, + type MonthlyMode, + type RecurrenceEditorValue, +} from '../../lib/recurrence'; + +interface RecurrenceEditorProps { + rule: RecurrenceRule | null; + eventStart: Date; + onSave: (rule: RecurrenceRule) => void; + onCancel: () => void; +} + +// Small popover-style select, matching the EventModal field pattern. +function InlineSelect({ + value, + options, + onChange, + flex, +}: { + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; + flex?: boolean; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const [open, setOpen] = React.useState(false); + const selected = options.find((o) => o.value === value); + return ( + + setOpen((v) => !v)}> + + {selected?.label ?? String(value)} + + + + {open && ( + + {options.map((opt) => ( + { + onChange(opt.value); + setOpen(false); + }} + > + {opt.label} + + ))} + + )} + + ); +} + +function NumberInput({ + value, + min, + max, + onChange, +}: { + value: number; + min: number; + max: number; + onChange: (value: number) => void; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const [text, setText] = React.useState(String(value)); + React.useEffect(() => setText(String(value)), [value]); + return ( + { + const n = parseInt(text, 10); + const clamped = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min; + setText(String(clamped)); + onChange(clamped); + }} + keyboardType="number-pad" + style={styles.numberInput} + /> + ); +} + +export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + + const [value, setValue] = React.useState(() => + editorValueFromRule(rule, eventStart), + ); + const [showUntilPicker, setShowUntilPicker] = React.useState(false); + + const update = (patch: Partial) => + setValue((prev) => ({ ...prev, ...patch })); + + const toggleWeekDay = (day: string) => { + setValue((prev) => ({ + ...prev, + weekDays: prev.weekDays.includes(day) + ? prev.weekDays.length > 1 + ? prev.weekDays.filter((d) => d !== day) + : prev.weekDays + : [...prev.weekDays, day], + })); + }; + + const builtRule = buildRuleFromEditorValue(value, eventStart); + const summary = buildRecurrenceSummary(builtRule); + + return ( + + + Every + update({ interval })} + /> + + flex + value={value.frequency} + options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))} + onChange={(frequency) => update({ frequency })} + /> + + + {value.frequency === 'weekly' && ( + + {WEEKDAYS.map((day) => { + const active = value.weekDays.includes(day); + return ( + toggleWeekDay(day)} + style={[styles.weekdayChip, active && styles.weekdayChipActive]} + > + + {weekdayName(day, 'short')} + + + ); + })} + + )} + + {value.frequency === 'yearly' && ( + + In + + flex + value={value.month} + options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({ + value: m, + label: capitalize(monthName(m)), + }))} + onChange={(month) => update({ month })} + /> + + )} + + {(value.frequency === 'monthly' || value.frequency === 'yearly') && ( + + + value={value.monthlyMode} + options={[ + { value: 'day', label: 'On day' }, + { value: 'nth', label: 'On the' }, + ]} + onChange={(monthlyMode) => update({ monthlyMode })} + /> + {value.monthlyMode === 'day' ? ( + update({ monthDay })} + /> + ) : ( + <> + + flex + value={value.nth} + options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))} + onChange={(nth) => update({ nth })} + /> + + flex + value={value.nthDay} + options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))} + onChange={(nthDay) => update({ nthDay })} + /> + + )} + + )} + + + Ends + + flex={value.endsMode === 'never'} + value={value.endsMode} + options={[ + { value: 'never', label: 'Never' }, + { value: 'on', label: 'On date' }, + { value: 'after', label: 'After' }, + ]} + onChange={(endsMode) => update({ endsMode })} + /> + {value.endsMode === 'on' && ( + setShowUntilPicker(true)}> + + {format(parseISO(value.untilDate), 'MMM d, yyyy')} + + + )} + {value.endsMode === 'after' && ( + <> + update({ count })} + /> + occurrences + + )} + + + + + {summary} + + + + + + + + {showUntilPicker && ( + { + setShowUntilPicker(false); + if (d) update({ untilDate: format(d, 'yyyy-MM-dd') }); + }} + /> + )} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.surface, + padding: spacing.md, + gap: spacing.sm, + }, + row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + rowLabel: { ...typography.body, color: c.textSecondary }, + muted: { ...typography.caption, color: c.textMuted, flexShrink: 1 }, + + numberInput: { + minWidth: 52, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.xs, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + color: c.text, + ...typography.body, + textAlign: 'center', + }, + + selectFlex: { flex: 1, minWidth: 0 }, + selectButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + minHeight: 36, + borderRadius: radius.xs, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + }, + selectText: { ...typography.body, color: c.text, flexShrink: 1 }, + + popover: { + marginTop: spacing.xs, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.xs, + backgroundColor: c.background, + overflow: 'hidden', + }, + popoverRow: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + popoverRowActive: { backgroundColor: c.primaryBg }, + popoverRowText: { ...typography.body, color: c.text }, + + weekdayRow: { flexDirection: 'row', gap: 4 }, + weekdayChip: { + flex: 1, + minWidth: 0, + paddingVertical: spacing.xs, + borderRadius: radius.xs, + borderWidth: 1, + borderColor: c.border, + alignItems: 'center', + }, + weekdayChipActive: { + borderColor: c.primary, + backgroundColor: c.primaryBg, + }, + weekdayChipText: { ...typography.caption, color: c.textMuted }, + weekdayChipTextActive: { color: c.primary }, + + footer: { + borderTopWidth: 1, + borderTopColor: c.border, + paddingTop: spacing.sm, + gap: spacing.sm, + }, + summary: { ...typography.caption, color: c.textMuted }, + footerButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm }, + }); +} diff --git a/src/components/files/ShareSheet.tsx b/src/components/files/ShareSheet.tsx new file mode 100644 index 00000000..4e76d273 --- /dev/null +++ b/src/components/files/ShareSheet.tsx @@ -0,0 +1,425 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableWithoutFeedback, + View, +} from 'react-native'; +import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native'; + +import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files'; +import type { FileNode, FileNodeRights, Principal } from '../../api/types'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; + +type RolePreset = 'read' | 'readWrite' | 'manager'; + +// Same presets the webmail's share dialog offers (#408). "custom" rights set +// by another client are shown as-is and left untouched until the user picks a +// preset for that principal. +const FILE_PRESETS: Record = { + read: { + mayRead: true, mayAddChildren: false, mayRename: false, + mayDelete: false, mayModifyContent: false, mayShare: false, + }, + readWrite: { + mayRead: true, mayAddChildren: true, mayRename: true, + mayDelete: true, mayModifyContent: true, mayShare: false, + }, + manager: { + mayRead: true, mayAddChildren: true, mayRename: true, + mayDelete: true, mayModifyContent: true, mayShare: true, + }, +}; + +const PRESET_LABELS: Record = { + read: 'Viewer', + readWrite: 'Editor', + manager: 'Manager', +}; + +const PRESET_ORDER: RolePreset[] = ['read', 'readWrite', 'manager']; + +function detectPreset(rights: FileNodeRights): RolePreset | 'custom' { + for (const preset of PRESET_ORDER) { + const expected = FILE_PRESETS[preset]; + const keys = Object.keys(expected) as (keyof FileNodeRights)[]; + if (keys.every((k) => expected[k] === (rights[k] ?? false))) return preset; + } + return 'custom'; +} + +interface ShareSheetProps { + node: FileNode | null; + onClose: () => void; + /** Fired after any successful share change so the parent can refresh. */ + onChanged: () => void; +} + +export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + + const [principals, setPrincipals] = useState([]); + const [loadingPrincipals, setLoadingPrincipals] = useState(false); + const [shares, setShares] = useState>({}); + const [search, setSearch] = useState(''); + const [savingId, setSavingId] = useState(null); + + useEffect(() => { + if (!node) return; + setShares(node.shareWith ?? {}); + setSearch(''); + setLoadingPrincipals(true); + getPrincipals() + .then(setPrincipals) + .catch(() => setPrincipals([])) + .finally(() => setLoadingPrincipals(false)); + }, [node]); + + const principalsById = useMemo(() => { + const map = new Map(); + for (const p of principals) map.set(p.id, p); + return map; + }, [principals]); + + const sharedEntries = useMemo( + () => Object.entries(shares).filter(([, rights]) => rights != null), + [shares], + ); + + const candidates = useMemo(() => { + const self = ownPrincipalId(); + const q = search.trim().toLowerCase(); + return principals + .filter((p) => p.id !== self && shares[p.id] == null) + .filter((p) => + !q || + p.name?.toLowerCase().includes(q) || + p.email?.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q)) + .slice(0, 25); + }, [principals, shares, search]); + + const applyShare = useCallback( + async (principalId: string, rights: FileNodeRights | null) => { + if (!node || savingId) return; + setSavingId(principalId); + try { + await setFileNodeShare(node.id, principalId, rights); + setShares((prev) => { + const next = { ...prev }; + if (rights == null) delete next[principalId]; + else next[principalId] = rights; + return next; + }); + onChanged(); + } catch (e) { + Alert.alert('Sharing failed', e instanceof Error ? e.message : String(e)); + } finally { + setSavingId(null); + } + }, + [node, savingId, onChanged], + ); + + if (!node) return null; + const Icon = isFolder(node) ? Folder : FileText; + + const renderPrincipalLabel = (principalId: string) => { + const p = principalsById.get(principalId); + return ( + + + {p?.description || p?.name || principalId} + + {p?.email ? ( + + {p.email} + + ) : null} + + ); + }; + + return ( + + + + + + + + + Share “{node.name}” + + + {isFolder(node) ? ( + + Sharing a folder shares everything inside it. + + ) : null} + + + {sharedEntries.length > 0 ? ( + + Shared with + {sharedEntries.map(([principalId, rights]) => { + const preset = detectPreset(rights); + const busy = savingId === principalId; + return ( + + {renderPrincipalLabel(principalId)} + + {PRESET_ORDER.map((p) => ( + void applyShare(principalId, FILE_PRESETS[p])} + disabled={busy} + style={[styles.chip, preset === p && styles.chipActive]} + > + + {PRESET_LABELS[p]} + + + ))} + {preset === 'custom' ? ( + Custom + ) : null} + + {busy ? ( + + ) : ( + void applyShare(principalId, null)} + hitSlop={8} + accessibilityLabel="Remove access" + > + + + )} + + ); + })} + + ) : ( + + + Not shared with anyone yet + + )} + + + Add people + + {loadingPrincipals ? ( + + ) : candidates.length === 0 ? ( + + {principals.length === 0 ? 'No other users found' : 'No matches'} + + ) : ( + candidates.map((p) => ( + void applyShare(p.id, FILE_PRESETS.read)} + disabled={savingId != null} + style={({ pressed }) => [ + styles.candidateRow, + pressed && { backgroundColor: c.surfaceHover }, + ]} + > + {renderPrincipalLabel(p.id)} + {savingId === p.id ? ( + + ) : ( + + )} + + )) + )} + + + + + Done + + + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: c.background, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xxl, + paddingHorizontal: spacing.md, + maxHeight: '80%', + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + }, + title: { + ...typography.h3, + color: c.text, + flexShrink: 1, + }, + subtitle: { + fontSize: typography.caption.fontSize, + color: c.textMuted, + paddingHorizontal: spacing.sm, + marginTop: 2, + }, + scroll: { + marginTop: spacing.sm, + }, + section: { + marginTop: spacing.md, + }, + sectionLabel: { + fontSize: typography.caption.fontSize, + fontWeight: '600' as const, + color: c.textMuted, + textTransform: 'uppercase' as const, + paddingHorizontal: spacing.sm, + marginBottom: spacing.xs, + }, + shareRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + principalInfo: { + flex: 1, + minWidth: 0, + }, + principalName: { + fontSize: typography.body.fontSize, + color: c.text, + }, + principalEmail: { + fontSize: typography.caption.fontSize, + color: c.textMuted, + }, + roleChips: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + chip: { + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + }, + chipActive: { + backgroundColor: c.primaryBg, + borderColor: c.primary, + }, + chipText: { + fontSize: typography.caption.fontSize, + color: c.textMuted, + }, + chipTextActive: { + color: c.primary, + fontWeight: '600' as const, + }, + customLabel: { + fontSize: typography.caption.fontSize, + color: c.textMuted, + fontStyle: 'italic' as const, + }, + emptyRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.md, + }, + emptyText: { + fontSize: typography.body.fontSize, + color: c.textMuted, + }, + input: { + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: c.text, + fontSize: typography.body.fontSize, + backgroundColor: c.surface, + marginHorizontal: spacing.sm, + }, + noMatches: { + fontSize: typography.caption.fontSize, + color: c.textMuted, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, + candidateRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderRadius: radius.md, + }, + doneBtn: { + marginTop: spacing.md, + alignSelf: 'stretch', + alignItems: 'center', + paddingVertical: spacing.sm, + borderRadius: radius.md, + backgroundColor: c.primary, + }, + doneText: { + color: c.primaryForeground, + fontWeight: '600' as const, + fontSize: typography.body.fontSize, + }, + }); +} diff --git a/src/lib/__tests__/recurrence-expansion.test.ts b/src/lib/__tests__/recurrence-expansion.test.ts index c687bd18..5241ba3c 100644 --- a/src/lib/__tests__/recurrence-expansion.test.ts +++ b/src/lib/__tests__/recurrence-expansion.test.ts @@ -174,3 +174,77 @@ describe('expandRecurringEvents - range filtering', () => { ]); }); }); + +describe('expandRecurringEvents - excludedRecurrenceRules', () => { + it('removes occurrences generated by excluded rules', () => { + const event = ev({ + start: '2025-01-06T09:00:00', + recurrenceRules: [{ frequency: 'daily' }], + excludedRecurrenceRules: [{ + frequency: 'weekly', + byDay: [{ day: 'tu' }], + }], + }); + // Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded + const result = expandRecurringEvents([event], '2025-01-06T00:00:00', '2025-01-13T00:00:00'); + const days = result.map((e) => e.start.substring(0, 10)); + expect(days).not.toContain('2025-01-07'); + expect(days).toContain('2025-01-06'); + expect(days).toContain('2025-01-08'); + expect(result).toHaveLength(6); + }); +}); + +describe('expandRecurringEvents - long-running series', () => { + it('expands a daily series started years before the visible range', () => { + const event = ev({ + start: '2022-01-03T09:00:00', + recurrenceRules: [{ frequency: 'daily' }], + }); + const result = expandRecurringEvents([event], '2025-06-02T00:00:00', '2025-06-05T00:00:00'); + expect(result.map((e) => e.start)).toEqual([ + '2025-06-02T09:00:00', + '2025-06-03T09:00:00', + '2025-06-04T09:00:00', + ]); + }); + + it('expands a weekly series started years before the visible range', () => { + const event = ev({ + start: '2020-01-06T09:00:00', // Monday + recurrenceRules: [{ frequency: 'weekly' }], + }); + const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00'); + const days = result.map((e) => e.start.substring(0, 10)); + expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']); + }); + + it('still respects count for old series (no fast-forward shortcut)', () => { + const event = ev({ + start: '2025-01-06T09:00:00', + recurrenceRules: [{ frequency: 'daily', count: 5 }], + }); + // Range far after the 5 occurrences ran out + const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00'); + expect(result).toHaveLength(0); + }); +}); + +describe('expandRecurringEvents - event timezone DST handling', () => { + it('keeps wall time across a DST transition in the event timezone', () => { + // America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after + const event = ev({ + start: '2025-03-03T10:00:00', + timeZone: 'America/New_York', + utcStart: '2025-03-03T15:00:00Z', + utcEnd: '2025-03-03T16:00:00Z', + recurrenceRules: [{ frequency: 'weekly' }], + }); + const result = expandRecurringEvents([event], '2025-03-03T00:00:00', '2025-03-17T00:00:00'); + const utcStarts = result.map((e) => e.utcStart); + expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5 + expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4 + const utcEnds = result.map((e) => e.utcEnd); + expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z'); + }); +}); diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index 7017b2d8..2d4d9b90 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -435,12 +435,63 @@ export function getEventColor( event: Pick, calendars: Calendar[], ): string { - if (event.color) return event.color; const calendarId = Object.keys(event.calendarIds || {})[0]; const cal = calendars.find((c) => c.id === calendarId); + // A local color override on a shared calendar wins over per-event colors, + // so the whole shared calendar paints uniformly in the viewer's chosen hue. + if (cal?.colorIsLocalOverride && cal.color) return cal.color; + if (event.color) return event.color; return getCalendarColor(cal); } +// ─── Per-viewer colors for shared calendars (#345) ─────── +// Shared calendars are recolored locally only: the viewer usually can't write +// the owner's calendar, and doing so would recolor it for everyone. Overrides +// live in the settings store keyed by sharedCalendarColorKey(). + +/** + * Stable key for a shared calendar's local color override. Built from the + * owning JMAP account + the calendar id so it stays unique across accounts. + */ +export function sharedCalendarColorKey( + cal: Pick, +): string { + return `${cal.accountId ?? ''}|${cal.id}`; +} + +/** + * Pick a random palette color not present in `usedColors`. Once every palette + * entry is taken, fall back to a random palette color (collisions are + * unavoidable past CALENDAR_PALETTE.length calendars). + */ +export function pickUnusedCalendarColor(usedColors: Iterable): string { + const used = new Set(); + for (const c of usedColors) { + if (c) used.add(c.toLowerCase()); + } + const available = CALENDAR_PALETTE.filter((c) => !used.has(c.toLowerCase())); + const pool = available.length > 0 ? available : CALENDAR_PALETTE; + return pool[Math.floor(Math.random() * pool.length)]; +} + +/** + * Apply each shared calendar's local color override (per-viewer recolor). + * The override replaces the calendar's color and wins over per-event colors + * via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars + * are passed through untouched. + */ +export function applySharedCalendarColors( + calendars: Calendar[], + overrides: Record, +): Calendar[] { + return calendars.map((cal) => { + if (!cal.isShared) return cal; + const override = overrides[sharedCalendarColorKey(cal)]; + if (!override) return cal; + return { ...cal, color: override, colorIsLocalOverride: true }; + }); +} + export function getPrimaryCalendarId( event: Pick, ): string | undefined { diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 7ad9a498..8f7e2953 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -89,13 +89,15 @@ export async function shareAttachment( name: string | undefined, type: string | undefined, email?: Email | null, + // Owning account for blobs shared by another principal (Files app). + accountId?: string, ): Promise { const filename = email ? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions()) : safeAttachmentName(name, type); const mimeType = type || 'application/octet-stream'; const dest = new File(Paths.cache, filename); - const url = getDownloadUrl(blobId, filename, mimeType); + const url = getDownloadUrl(blobId, filename, mimeType, accountId); const downloaded = await downloadInto(url, dest, Paths.cache); if (!(await Sharing.isAvailableAsync())) { throw new Error('Sharing is not available on this device'); @@ -116,13 +118,15 @@ export async function downloadAttachment( name: string | undefined, type: string | undefined, email?: Email | null, + // Owning account for blobs shared by another principal (Files app). + accountId?: string, ): Promise { const filename = email ? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions()) : safeAttachmentName(name, type); const mimeType = type || 'application/octet-stream'; const dest = new File(Paths.document, filename); - const url = getDownloadUrl(blobId, filename, mimeType); + const url = getDownloadUrl(blobId, filename, mimeType, accountId); const downloaded = await downloadInto(url, dest, Paths.document); if (!(await Sharing.isAvailableAsync())) { throw new Error('Sharing is not available on this device'); diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index dc765017..31b643ac 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -11,7 +11,7 @@ * on the client. */ -import { addDays, addMonths, addWeeks, addYears, format, parseISO } from 'date-fns'; +import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays, format, parseISO } from 'date-fns'; import type { CalendarEvent, RecurrenceRule } from '../api/types'; type NDay = { day: string; nthOfPeriod?: number }; @@ -60,6 +60,10 @@ export function expandRecurringEvents( return result; } +function occurrenceDateKey(master: CalendarEvent, date: Date): string { + return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString(); +} + function expandEvent( master: CalendarEvent, rangeStart: Date, @@ -73,13 +77,23 @@ function expandEvent( const occurrences: CalendarEvent[] = []; const seenDates = new Set(); + // RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules + // (iCalendar EXRULE) are removed from the recurrence set. + const excludedDates = new Set(); + for (const exRule of master.excludedRecurrenceRules || []) { + // includeStartDate=false: "the series start is always an occurrence" + // applies to recurrence rules, not to exclusion rules. + for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) { + excludedDates.add(occurrenceDateKey(master, date)); + } + } + for (const rule of rules) { const dates = generateDates(eventStart, rule, rangeStart, rangeEnd); for (const date of dates) { - const dateKey = master.showWithoutTime - ? format(date, 'yyyy-MM-dd') - : date.toISOString(); + const dateKey = occurrenceDateKey(master, date); + if (excludedDates.has(dateKey)) continue; if (seenDates.has(dateKey)) continue; seenDates.add(dateKey); @@ -104,9 +118,9 @@ function expandEvent( if (isNaN(overrideDate.getTime())) continue; if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue; - const dateKey = master.showWithoutTime - ? format(overrideDate, 'yyyy-MM-dd') - : overrideDate.toISOString(); + // Overrides take precedence over excludedRecurrenceRules (they re-add + // a concrete instance), so only dedupe against already-generated dates. + const dateKey = occurrenceDateKey(master, overrideDate); if (seenDates.has(dateKey)) continue; seenDates.add(dateKey); @@ -116,6 +130,65 @@ function expandEvent( return occurrences; } +// Cache Intl formatters per IANA timezone id — constructing them is expensive +// and expansion runs over hundreds of occurrences. `null` marks ids the +// runtime rejected so we don't retry them. +const tzFormatterCache = new Map(); + +function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null { + let formatter = tzFormatterCache.get(timeZone); + if (formatter === undefined) { + try { + formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + } catch { + formatter = null; + } + tzFormatterCache.set(timeZone, formatter); + } + return formatter; +} + +/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */ +function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number { + const map: Record = {}; + for (const part of formatter.formatToParts(new Date(instant))) { + if (part.type !== 'literal') map[part.type] = part.value; + } + const hour = map.hour === '24' ? 0 : Number(map.hour); + return Date.UTC( + Number(map.year), Number(map.month) - 1, Number(map.day), + hour, Number(map.minute), Number(map.second), + ); +} + +/** + * Interpret the wall-clock fields of `wall` as a local time in `timeZone` + * and return the corresponding UTC instant. Two fixup iterations converge + * for all real offsets, including across DST transitions. + */ +function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { + const formatter = getTzFormatter(timeZone); + if (!formatter) return null; + const wallAsUtc = Date.UTC( + wall.getFullYear(), wall.getMonth(), wall.getDate(), + wall.getHours(), wall.getMinutes(), wall.getSeconds(), + ); + let guess = wallAsUtc; + for (let i = 0; i < 2; i++) { + guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess); + } + return new Date(guess); +} + function createOccurrence( master: CalendarEvent, date: Date, @@ -130,16 +203,33 @@ function createOccurrence( // getEventEndDate return the occurrence times rather than the master's. let utcStart: string | undefined; let utcEnd: string | undefined; - if (!master.showWithoutTime && master.utcStart && master.start) { - const masterLocal = parseISO(master.start); - const masterUtc = parseISO(master.utcStart); - const offsetMs = masterUtc.getTime() - masterLocal.getTime(); - utcStart = new Date(date.getTime() + offsetMs).toISOString(); + if (!master.showWithoutTime) { + let durationMs: number | null = null; + if (master.utcStart && master.utcEnd) { + const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime(); + if (!isNaN(ms)) durationMs = ms; + } - if (master.utcEnd) { - const masterUtcEnd = parseISO(master.utcEnd); - const durationMs = masterUtcEnd.getTime() - masterUtc.getTime(); - utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString(); + // Convert the occurrence's wall time using the event's own timezone, so + // occurrences on the other side of a DST transition in that zone keep + // their wall time. Reusing the master's fixed UTC offset (the fallback + // below) would shift them by the DST delta. + const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null; + if (zoned) { + utcStart = zoned.toISOString(); + if (durationMs !== null) { + utcEnd = new Date(zoned.getTime() + durationMs).toISOString(); + } + } else if (master.utcStart && master.start) { + // Floating events (or an unrecognized timezone id): keep the master's + // offset, which by definition doesn't vary. + const masterLocal = parseISO(master.start); + const masterUtc = parseISO(master.utcStart); + const offsetMs = masterUtc.getTime() - masterLocal.getTime(); + utcStart = new Date(date.getTime() + offsetMs).toISOString(); + if (durationMs !== null) { + utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString(); + } } } @@ -207,6 +297,7 @@ function generateDates( rawRule: RecurrenceRule, rangeStart: Date, rangeEnd: Date, + includeStartDate = true, ): Date[] { const rule = addImplicitByX(rawRule, eventStart); const dates: Date[] = []; @@ -214,21 +305,34 @@ function generateDates( const countLimit = rule.count || Infinity; const until = rule.until ? parseISO(rule.until) : null; let totalCount = 0; - let current = new Date(eventStart); + // Without a `count` limit we can jump straight to the period containing + // the visible range. With one, every occurrence since the series start + // must be generated so it counts against `count`. + let current = rule.count + ? new Date(eventStart) + : fastForwardToRange(eventStart, rule.frequency, interval, rangeStart); const maxIterations = 2000; + // Cap on *emitted* (in-range) dates. This must not count occurrences + // before rangeStart, otherwise a series started long ago (e.g. a daily + // event from two years back) exhausts the budget before reaching the + // visible range and silently renders nothing. const maxOccurrences = 500; let iterations = 0; while (iterations++ < maxIterations) { - if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + if (totalCount >= countLimit || dates.length >= maxOccurrences) break; if (until && current > until) break; - if ( - current >= rangeEnd - && rule.frequency !== 'yearly' - && rule.frequency !== 'monthly' - && rule.frequency !== 'weekly' - ) break; + // Stop once no candidate in this or a later period can fall before + // rangeEnd. Monthly/yearly candidates may precede the period anchor + // within the same month/year, so floor the anchor before comparing. + if (rule.frequency === 'monthly') { + if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break; + } else if (rule.frequency === 'yearly') { + if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break; + } else if (current >= rangeEnd) { + break; + } const candidates = generateCandidatesForPeriod(current, rule, eventStart); @@ -238,7 +342,7 @@ function generateDates( for (const d of filtered) { if (until && d > until) break; - if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + if (totalCount >= countLimit || dates.length >= maxOccurrences) break; // Spec rule 4: eliminate dates before event start if (d < eventStart) continue; @@ -250,7 +354,7 @@ function generateDates( if (d >= rangeEnd) break; } - if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + if (totalCount >= countLimit || dates.length >= maxOccurrences) break; current = advancePeriod(current, rule.frequency, interval); if (current <= eventStart && iterations === 1) { @@ -259,7 +363,7 @@ function generateDates( } // Spec rule 1: the start date-time is ALWAYS the first occurrence. - if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) { + if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) { if (eventStart >= rangeStart && eventStart < rangeEnd) { if (!dates.some((d) => d.getTime() === eventStart.getTime())) { dates.unshift(eventStart); @@ -505,6 +609,66 @@ function advancePeriod( } } +// ─── Fast-forward to the visible range ─────────────────── +// Jump to the period just before the visible range in O(1), so long-running +// series don't burn the iteration budget on years of out-of-range periods. +// Only valid for rules without `count` (counted rules must enumerate every +// occurrence from the series start). +function fastForwardToRange( + eventStart: Date, + frequency: RecurrenceRule['frequency'], + interval: number, + rangeStart: Date, +): Date { + if (eventStart >= rangeStart) return new Date(eventStart); + + let periods: number; + switch (frequency) { + case 'secondly': + periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval)); + break; + case 'minutely': + periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval)); + break; + case 'hourly': + periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval)); + break; + case 'daily': + periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval); + break; + case 'weekly': + periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval)); + break; + case 'monthly': { + const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12 + + (rangeStart.getMonth() - eventStart.getMonth()); + periods = Math.floor(months / interval); + break; + } + case 'yearly': + periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval); + break; + default: + return new Date(eventStart); + } + + // Land one full period early so candidates inside the boundary period + // (which can precede the period anchor) are still generated. + periods = Math.max(0, periods - 1); + if (periods === 0) return new Date(eventStart); + + switch (frequency) { + case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000); + case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000); + case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000); + case 'daily': return addDays(eventStart, periods * interval); + case 'weekly': return addWeeks(eventStart, periods * interval); + case 'monthly': return addMonths(eventStart, periods * interval); + case 'yearly': return addYears(eventStart, periods * interval); + default: return new Date(eventStart); + } +} + function expandByDayInMonth( year: number, month: number, diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts new file mode 100644 index 00000000..5ca183f7 --- /dev/null +++ b/src/lib/recurrence.ts @@ -0,0 +1,246 @@ +/** + * Custom recurrence rule helpers for the event editor. Mobile port of + * webmail's components/calendar/recurrence-editor.tsx logic (rule detection, + * construction, and the human-readable summary). + */ + +import { format } from 'date-fns'; +import type { RecurrenceRule } from '../api/types'; + +export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly'; +export type MonthlyMode = 'day' | 'nth'; +export type EndsMode = 'never' | 'on' | 'after'; + +export const EDITOR_FREQUENCIES: EditorFrequency[] = ['daily', 'weekly', 'monthly', 'yearly']; + +// JSCalendar weekday ids in display order (week starts Monday, matching the +// firstDayOfWeek the built rules declare). +export const WEEKDAYS: string[] = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su']; +const DAY_TO_REF_DATE: Record = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 }; +export const INDEX_TO_DAY = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa']; + +export const UNIT_LABELS: Record = { + daily: 'days', + weekly: 'weeks', + monthly: 'months', + yearly: 'years', +}; + +// 2024-01-01 is a Monday — used to render weekday names via date-fns. +export function weekdayName(day: string, style: 'long' | 'short' = 'long'): string { + const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1); + return format(ref, style === 'long' ? 'EEEE' : 'EEE'); +} + +export function monthName(month: number): string { + return format(new Date(2024, month - 1, 1), 'LLLL'); +} + +const NTH_LABELS: Record = { + 1: 'first', + 2: 'second', + 3: 'third', + 4: 'fourth', + [-1]: 'last', +}; + +export function nthLabel(nth: number): string { + return NTH_LABELS[nth] ?? String(nth); +} + +export function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Extract an "nth weekday" pattern from a rule, accepting both the + * byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding. + */ +export function getNthDay(rule: RecurrenceRule): { day: string; nth: number } | null { + if (rule.byDay?.length === 1) { + const nd = rule.byDay[0]; + if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod }; + if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] }; + } + return null; +} + +/** + * True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly + * dropdown presets produce, i.e. it needs no custom editor to represent. + */ +export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean { + return ( + (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) && + (!rule.interval || rule.interval === 1) && + !rule.byDay?.length && + !rule.byMonthDay?.length && + !rule.byMonth?.length && + !rule.byYearDay?.length && + !rule.byWeekNo?.length && + !rule.bySetPosition?.length && + !rule.count && + !rule.until + ); +} + +/** + * Human-readable summary of a recurrence rule, e.g. + * "Every 2 months on the third Thursday · 12 occurrences". + * Returns null for frequencies the UI cannot describe (hourly etc.). + */ +export function buildRecurrenceSummary(rule: RecurrenceRule): string | null { + const interval = rule.interval || 1; + let base: string; + switch (rule.frequency) { + case 'daily': + base = interval > 1 ? `Every ${interval} days` : 'Daily'; + break; + case 'weekly': + base = interval > 1 ? `Every ${interval} weeks` : 'Weekly'; + break; + case 'monthly': + base = interval > 1 ? `Every ${interval} months` : 'Monthly'; + break; + case 'yearly': + base = interval > 1 ? `Every ${interval} years` : 'Yearly'; + break; + default: + return null; + } + + const parts = [base]; + + if (rule.frequency === 'weekly' && rule.byDay?.length) { + const days = rule.byDay + .filter((d) => WEEKDAYS.includes(d.day)) + .sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day)) + .map((d) => weekdayName(d.day, 'short')) + .join(', '); + if (days) parts.push(`on ${days}`); + } + + if (rule.frequency === 'monthly' || rule.frequency === 'yearly') { + if (rule.frequency === 'yearly' && rule.byMonth?.length) { + const m = parseInt(rule.byMonth[0], 10); + if (m >= 1 && m <= 12) parts.push(`in ${monthName(m)}`); + } + const nthDay = getNthDay(rule); + if (nthDay) { + parts.push(`on the ${nthLabel(nthDay.nth)} ${weekdayName(nthDay.day)}`); + } else if (rule.byMonthDay?.length) { + parts.push(`on day ${rule.byMonthDay[0]}`); + } + } + + let summary = parts.join(' '); + if (rule.count) { + summary += ` · ${rule.count} ${rule.count === 1 ? 'occurrence' : 'occurrences'}`; + } else if (rule.until) { + const d = new Date(rule.until); + if (!isNaN(d.getTime())) { + summary += ` · until ${format(d, 'MMM d, yyyy')}`; + } + } + return summary; +} + +export interface RecurrenceEditorValue { + frequency: EditorFrequency; + interval: number; + weekDays: string[]; + monthlyMode: MonthlyMode; + monthDay: number; + nth: number; + nthDay: string; + month: number; + endsMode: EndsMode; + untilDate: string; // yyyy-MM-dd + count: number; +} + +/** Build the editor's initial state from an existing rule (or defaults). */ +export function editorValueFromRule( + rule: RecurrenceRule | null, + eventStart: Date, +): RecurrenceEditorValue { + const startDay = INDEX_TO_DAY[eventStart.getDay()]; + const nthDayInfo = rule ? getNthDay(rule) : null; + + const weekDays = (() => { + if (rule?.frequency === 'weekly' && rule.byDay?.length) { + const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d)); + if (days.length) return days; + } + return [startDay]; + })(); + + const monthFromRule = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN; + const untilFromRule = (() => { + if (rule?.until) { + const d = new Date(rule.until); + if (!isNaN(d.getTime())) return format(d, 'yyyy-MM-dd'); + } + const inAYear = new Date(eventStart); + inAYear.setFullYear(inAYear.getFullYear() + 1); + return format(inAYear, 'yyyy-MM-dd'); + })(); + + return { + frequency: rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) + ? (rule.frequency as EditorFrequency) + : 'weekly', + interval: rule?.interval || 1, + weekDays, + monthlyMode: nthDayInfo ? 'nth' : 'day', + monthDay: rule?.byMonthDay?.[0] && rule.byMonthDay[0] >= 1 && rule.byMonthDay[0] <= 31 + ? rule.byMonthDay[0] + : eventStart.getDate(), + nth: nthDayInfo && (nthDayInfo.nth === -1 || (nthDayInfo.nth >= 1 && nthDayInfo.nth <= 4)) + ? nthDayInfo.nth + : Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1), + nthDay: nthDayInfo && WEEKDAYS.includes(nthDayInfo.day) ? nthDayInfo.day : startDay, + month: monthFromRule >= 1 && monthFromRule <= 12 ? monthFromRule : eventStart.getMonth() + 1, + endsMode: rule?.count ? 'after' : rule?.until ? 'on' : 'never', + untilDate: untilFromRule, + count: rule?.count ?? 12, + }; +} + +/** Build the JSCalendar rule the editor state describes. */ +export function buildRuleFromEditorValue( + value: RecurrenceEditorValue, + eventStart: Date, +): RecurrenceRule { + const startDay = INDEX_TO_DAY[eventStart.getDay()]; + const built: RecurrenceRule = { + '@type': 'RecurrenceRule', + frequency: value.frequency, + interval: Math.max(1, value.interval), + rscale: 'gregorian', + skip: 'omit', + firstDayOfWeek: 'mo', + }; + + if (value.endsMode === 'after') { + built.count = Math.max(1, value.count); + } else if (value.endsMode === 'on' && value.untilDate) { + built.until = `${value.untilDate}T23:59:59`; + } + + if (value.frequency === 'weekly') { + const days = value.weekDays.length ? value.weekDays : [startDay]; + built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day })); + } else if (value.frequency === 'monthly' || value.frequency === 'yearly') { + if (value.monthlyMode === 'nth') { + built.byDay = [{ day: value.nthDay, nthOfPeriod: value.nth }]; + } else { + built.byMonthDay = [Math.min(31, Math.max(1, value.monthDay))]; + } + if (value.frequency === 'yearly') { + built.byMonth = [String(value.month)]; + } + } + + return built; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index a66a7b28..3dd16a8d 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -52,8 +52,11 @@ import { TasksSheet } from '../components/calendar/TasksSheet'; import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; import { + applySharedCalendarColors, buildEventDayIndex, eventsOnDayFromIndex, + pickUnusedCalendarColor, + sharedCalendarColorKey, type EventDayIndex, } from '../lib/calendar-utils'; import { buildReplyTo } from '../lib/calendar-invitation'; @@ -117,6 +120,9 @@ export default function CalendarScreen() { const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat); const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar); const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); + const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); + const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); + const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor); const contacts = useContactsStore((s) => s.contacts); const [currentDate, setCurrentDate] = React.useState(new Date()); @@ -154,6 +160,7 @@ export default function CalendarScreen() { const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete); const deleteTask = useCalendarStore((s) => s.deleteTask); const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility); + const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const storeCalendars = useCalendarStore((s) => s.calendars); const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds); const loading = useCalendarStore((s) => s.loading); @@ -169,9 +176,41 @@ export default function CalendarScreen() { return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString()); }, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]); + // Per-viewer recolor (#345): shared calendars get the viewer's local color + // override applied before anything renders. Personal calendars pass through. + const displayCalendars = React.useMemo( + () => applySharedCalendarColors(storeCalendars, sharedCalendarColors), + [storeCalendars, sharedCalendarColors], + ); + + // Auto-assign a random, not-yet-used palette color to any freshly shared + // calendar so multiple shared calendars don't collide on one color. Runs + // once per calendar (guarded by the presence of an existing key), and the + // user can still overwrite it from the sidebar. + React.useEffect(() => { + const missing = storeCalendars.filter( + (cal) => cal.isShared && !sharedCalendarColors[sharedCalendarColorKey(cal)], + ); + if (missing.length === 0) return; + // Seed "used" with personal calendar colors plus already-assigned shared + // overrides so the picks stay distinct from what's already on screen. + const used = new Set(); + for (const cal of storeCalendars) { + if (!cal.isShared && cal.color) used.add(cal.color.toLowerCase()); + } + for (const color of Object.values(sharedCalendarColors)) { + if (color) used.add(color.toLowerCase()); + } + for (const cal of missing) { + const color = pickUnusedCalendarColor(used); + used.add(color.toLowerCase()); + setSharedCalendarColor(sharedCalendarColorKey(cal), color); + } + }, [storeCalendars, sharedCalendarColors, setSharedCalendarColor]); + const allCalendars = React.useMemo( - () => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars), - [storeCalendars, showBirthdayCalendar], + () => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars), + [displayCalendars, showBirthdayCalendar], ); const allEvents = React.useMemo( () => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents), @@ -491,6 +530,13 @@ export default function CalendarScreen() { onClose={() => setSidebarVisible(false)} onImport={() => { setSidebarVisible(false); setImportVisible(true); }} onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }} + onSetDefault={(cal) => { void setDefaultCalendar(cal.id); }} + onSetColor={(cal, color) => setSharedCalendarColor(sharedCalendarColorKey(cal), color)} + onResetColor={(cal) => { + // Drop the local override; the auto-assign effect picks a fresh + // unused color (so it never reverts to a collision). + removeSharedCalendarColor(sharedCalendarColorKey(cal)); + }} /> !cal.isShared)} onClose={() => setImportVisible(false)} onImport={importEvents} /> diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 2279ddec..3c4d28aa 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -16,7 +16,7 @@ import { import { ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo, FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, - MoreVertical, Pencil, Share2, Trash2, Upload, X, Download, + MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, } from 'lucide-react-native'; // expo-document-picker is loaded lazily on first upload. Its native module @@ -38,9 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null { } import { - createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix, - isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode, - uploadFileNode, + createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts, isCrossAccountId, + isFolder, renameFileNode, supportsSharing, uploadFileNode, } from '../api/files'; import { downloadAttachment, shareAttachment } from '../lib/email-export'; import type { FileNode } from '../api/types'; @@ -48,9 +47,9 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { useSettingsStore, type FilesViewMode } from '../stores/settings-store'; import Dialog from '../components/Dialog'; +import ShareSheet from '../components/files/ShareSheet'; -// A file row carries the resolved display name alongside the original -// server-side name (the path-encoded `node.name` field). +// A file row carries the display name alongside the rest of the node. interface FileRow extends FileNode { displayName: string; } @@ -108,7 +107,10 @@ export default function FilesScreen() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const [path, setPath] = useState([]); + // Folder navigation stack. Hierarchy is real parentId nesting (#379) — each + // entry is the folder's node id (namespaced "accountId:nodeId" inside a + // shared-with-me subtree) plus its name for the breadcrumb. + const [path, setPath] = useState<{ id: string; name: string }[]>([]); const [allNodes, setAllNodes] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -124,6 +126,7 @@ export default function FilesScreen() { const [renameTarget, setRenameTarget] = useState(null); const [renameValue, setRenameValue] = useState(''); const [actionsTarget, setActionsTarget] = useState(null); + const [shareTarget, setShareTarget] = useState(null); const showIcons = useSettingsStore((s) => s.filesShowIcons); const coloredIcons = useSettingsStore((s) => s.filesColoredIcons); @@ -137,8 +140,13 @@ export default function FilesScreen() { const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; const selectionMode = selection.size > 0; - const prefix = useMemo(() => pathToPrefix(path), [path]); - const headerName = path.length === 0 ? 'Files' : path[path.length - 1]; + const currentParentId = path.length === 0 ? null : path[path.length - 1].id; + // True while browsing inside a folder another principal shared with us. + // Creates/uploads would have to route to the owner's account, which this + // screen doesn't do (matches the webmail's Files app); writes are hidden. + const inSharedSubtree = isCrossAccountId(currentParentId); + const headerName = path.length === 0 ? 'Files' : path[path.length - 1].name; + const sharingEnabled = supportsSharing(); const loadFiles = useCallback( async (mode: 'initial' | 'refresh' = 'initial') => { @@ -146,7 +154,9 @@ export default function FilesScreen() { else setLoading(true); setError(null); try { - const nodes = await getAllFileNodes(); + // One fetch across all accessible accounts: own files plus nodes other + // principals shared with us (tagged isShared, ids namespaced). + const nodes = await getAllFileNodesAcrossAccounts(); setAllNodes(nodes); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load files'); @@ -162,33 +172,49 @@ export default function FilesScreen() { void loadFiles(); }, [loadFiles]); - // If we navigated into a folder that no longer exists (it was deleted, or - // we never had it), walk back up until we land on one that does. The check - // looks for a directory node whose serverName matches the encoded path. + // If we navigated into a folder that no longer exists (deleted, or its + // share was revoked), cut the stack back to the deepest ancestor that does. useEffect(() => { if (allNodes.length === 0) return; setPath((prev) => { - let next = prev; - while (next.length > 0) { - const parentPrefix = pathToPrefix(next.slice(0, -1)); - const expectedName = parentPrefix + next[next.length - 1]; - const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n)); - if (exists) break; - next = next.slice(0, -1); + const byId = new Map(allNodes.map((n) => [n.id, n])); + let cut = prev.length; + for (let i = 0; i < prev.length; i++) { + const node = byId.get(prev[i].id); + if (!node || !isFolder(node)) { + cut = i; + break; + } } - return next.length === prev.length ? prev : next; + return cut === prev.length ? prev : prev.slice(0, cut); }); }, [allNodes]); const visibleFiles = useMemo(() => { - const rows = allNodes - .filter((n) => isDirectChildOfPrefix(n, prefix)) - .map((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) })) + let nodes: FileNode[]; + if (currentParentId === null) { + // Root: our own top-level nodes, plus the roots of every shared-with-me + // subtree (a shared node whose parent isn't visible to us). + const idSet = new Set(allNodes.map((n) => n.id)); + nodes = allNodes.filter((n) => + n.isShared + ? n.parentId == null || !idSet.has(n.parentId) + : (n.parentId ?? null) === null, + ); + } else { + nodes = allNodes.filter((n) => (n.parentId ?? null) === currentParentId); + } + const rows = nodes + .map((n) => ({ ...n, displayName: n.name })) .filter((r) => showHiddenFiles || !r.displayName.startsWith('.')); const dir = sortDir === 'desc' ? -1 : 1; return rows.sort((a, b) => { - const aDir = isDirectory(a) ? 0 : 1; - const bDir = isDirectory(b) ? 0 : 1; + // Own content first, shared-with-me entries after (root only). + const aShared = a.isShared ? 1 : 0; + const bShared = b.isShared ? 1 : 0; + if (aShared !== bShared) return aShared - bShared; + const aDir = isFolder(a) ? 0 : 1; + const bDir = isFolder(b) ? 0 : 1; if (aDir !== bDir) return aDir - bDir; switch (sortKey) { case 'size': @@ -203,15 +229,18 @@ export default function FilesScreen() { return a.displayName.localeCompare(b.displayName) * dir; } }); - }, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]); + }, [allNodes, currentParentId, showHiddenFiles, sortKey, sortDir]); const clearSelection = useCallback(() => setSelection(new Set()), []); - const toggleSelect = useCallback((id: string) => { + // Shared-with-me rows stay out of multi-select: batch delete routes to our + // own account and would fail (or worse, mismatch) on namespaced ids. + const toggleSelect = useCallback((row: FileRow) => { + if (row.isShared) return; setSelection((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); return next; }); }, []); @@ -220,7 +249,7 @@ export default function FilesScreen() { if (!row.blobId) return; setBusyId(row.id); try { - await shareAttachment(row.blobId, row.displayName, row.type); + await shareAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId); } catch (e) { Alert.alert('Preview failed', e instanceof Error ? e.message : String(e)); } finally { @@ -232,7 +261,7 @@ export default function FilesScreen() { if (!row.blobId) return; setBusyId(row.id); try { - await downloadAttachment(row.blobId, row.displayName, row.type); + await downloadAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId); } catch (e) { Alert.alert('Download failed', e instanceof Error ? e.message : String(e)); } finally { @@ -243,11 +272,11 @@ export default function FilesScreen() { const handleRowPress = useCallback( (row: FileRow) => { if (selectionMode) { - toggleSelect(row.id); + toggleSelect(row); return; } - if (isDirectory(row)) { - setPath((p) => [...p, row.displayName]); + if (isFolder(row)) { + setPath((p) => [...p, { id: row.id, name: row.displayName }]); return; } void previewFile(row); @@ -256,7 +285,7 @@ export default function FilesScreen() { ); const handleRowLongPress = useCallback((row: FileRow) => { - toggleSelect(row.id); + toggleSelect(row); }, [toggleSelect]); const goBack = useCallback(() => { @@ -277,19 +306,19 @@ export default function FilesScreen() { const submitNewFolder = useCallback(async () => { const name = newFolderName.trim(); if (!name) return; - if (name.includes(PATH_SEP) || name.includes('/')) { + if (name.includes('/')) { Alert.alert('Invalid name', 'Folder names cannot contain "/".'); return; } setNewFolderOpen(false); setNewFolderName(''); try { - await createFolder(name, path); + await createFolder(name, currentParentId); await loadFiles('refresh'); } catch (e) { Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e)); } - }, [newFolderName, path, loadFiles]); + }, [newFolderName, currentParentId, loadFiles]); const submitRename = useCallback(async () => { if (!renameTarget) return; @@ -298,7 +327,7 @@ export default function FilesScreen() { setRenameTarget(null); return; } - if (name.includes(PATH_SEP) || name.includes('/')) { + if (name.includes('/')) { Alert.alert('Invalid name', 'Names cannot contain "/".'); return; } @@ -306,12 +335,12 @@ export default function FilesScreen() { setRenameTarget(null); setRenameValue(''); try { - await renameFileNode(target, name, path, allNodes); + await renameFileNode(target.id, name); await loadFiles('refresh'); } catch (e) { Alert.alert('Rename failed', e instanceof Error ? e.message : String(e)); } - }, [renameTarget, renameValue, path, allNodes, loadFiles]); + }, [renameTarget, renameValue, loadFiles]); const startUpload = useCallback(async () => { if (uploading) return; @@ -341,7 +370,7 @@ export default function FilesScreen() { asset.uri, asset.name, asset.mimeType || 'application/octet-stream', - path, + currentParentId, ); await loadFiles('refresh'); } catch (e) { @@ -349,7 +378,7 @@ export default function FilesScreen() { } finally { setUploading(false); } - }, [uploading, path, loadFiles]); + }, [uploading, currentParentId, loadFiles]); const requestDelete = useCallback((rows: FileRow[]) => { if (rows.length === 0) return; @@ -362,12 +391,13 @@ export default function FilesScreen() { setConfirmDelete(null); clearSelection(); try { - await deleteWithDescendants(targets, allNodes); + // The server removes folder descendants (onDestroyRemoveChildren). + await deleteFileNodes(targets.map((t) => t.id)); await loadFiles('refresh'); } catch (e) { Alert.alert('Delete failed', e instanceof Error ? e.message : String(e)); } - }, [confirmDelete, allNodes, clearSelection, loadFiles]); + }, [confirmDelete, clearSelection, loadFiles]); const renderHeader = () => { const canBack = path.length > 0; @@ -409,27 +439,31 @@ export default function FilesScreen() { - setNewFolderOpen(true)} - hitSlop={8} - style={styles.headerBtn} - accessibilityLabel="New folder" - > - - - void startUpload()} - hitSlop={8} - style={styles.headerBtn} - accessibilityLabel="Upload file" - disabled={uploading} - > - {uploading ? ( - - ) : ( - - )} - + {!inSharedSubtree ? ( + setNewFolderOpen(true)} + hitSlop={8} + style={styles.headerBtn} + accessibilityLabel="New folder" + > + + + ) : null} + {!inSharedSubtree ? ( + void startUpload()} + hitSlop={8} + style={styles.headerBtn} + accessibilityLabel="Upload file" + disabled={uploading} + > + {uploading ? ( + + ) : ( + + )} + + ) : null} {canBack ? ( - {['Files', ...path].join(' / ')} + {['Files', ...path.map((p) => p.name)].join(' / ')} ) : null} @@ -450,13 +484,14 @@ export default function FilesScreen() { }; const renderRow = (item: FileRow) => { - const isDir = isDirectory(item); + const isDir = isFolder(item); const Icon = isDir ? Folder : pickFileIcon(item.displayName); const tint = isDir ? (coloredIcons ? c.calendar.blue : c.textMuted) : (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted); const selected = selection.has(item.id); const busy = busyId === item.id; + const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0; return ( handleRowPress(item)} @@ -470,10 +505,22 @@ export default function FilesScreen() { > {showIcons && } - - {item.displayName} - + + + {item.displayName} + + {item.isShared ? ( + + ) : sharedOut ? ( + + ) : null} + + {item.isShared && item.accountName ? ( + + Shared by {item.accountName} + + ) : null} {item.size != null && !isDir ? ( {formatFileSize(item.size)} ) : null} @@ -501,12 +548,13 @@ export default function FilesScreen() { }; const renderGridCell = (item: FileRow) => { - const isDir = isDirectory(item); + const isDir = isFolder(item); const Icon = isDir ? Folder : pickFileIcon(item.displayName); const tint = isDir ? (coloredIcons ? c.calendar.blue : c.textMuted) : (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted); const selected = selection.has(item.id); + const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0; return ( handleRowPress(item)} @@ -523,10 +571,21 @@ export default function FilesScreen() { ) : ( )} - - {item.displayName} - - {item.size != null && !isDir ? ( + + + {item.displayName} + + {item.isShared ? ( + + ) : sharedOut ? ( + + ) : null} + + {item.isShared && item.accountName ? ( + + Shared by {item.accountName} + + ) : item.size != null && !isDir ? ( {formatFileSize(item.size)} @@ -627,6 +686,7 @@ export default function FilesScreen() { setActionsTarget(null)} onPreview={(r) => { setActionsTarget(null); @@ -636,6 +696,10 @@ export default function FilesScreen() { setActionsTarget(null); void downloadFile(r); }} + onShare={(r) => { + setActionsTarget(null); + setShareTarget(r); + }} onRename={(r) => { setActionsTarget(null); setRenameTarget(r); @@ -647,6 +711,12 @@ export default function FilesScreen() { }} /> + setShareTarget(null)} + onChanged={() => void loadFiles('refresh')} + /> + void; onPreview: (r: FileRow) => void; onDownload: (r: FileRow) => void; + onShare: (r: FileRow) => void; onRename: (r: FileRow) => void; onDelete: (r: FileRow) => void; } -function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) { +function ActionsSheet({ target, sharingEnabled, onClose, onPreview, onDownload, onShare, onRename, onDelete }: ActionsSheetProps) { const c = useColors(); const styles = React.useMemo(() => makeSheetStyles(c), [c]); if (!target) return null; - const isDir = isDirectory(target); + const isDir = isFolder(target); + // Owned nodes report full rights (or no myRights at all); shared-with-me + // nodes are managed by their owner, and write ops would have to route to + // the owner's account — so they only get preview/download here. + const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true); return ( @@ -755,14 +831,24 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele Save to device ) : null} - onRename(target)}> - - Rename - - onDelete(target)}> - - Delete - + {canShare ? ( + onShare(target)}> + + Sharing & access + + ) : null} + {!target.isShared ? ( + onRename(target)}> + + Rename + + ) : null} + {!target.isShared ? ( + onDelete(target)}> + + Delete + + ) : null} @@ -856,9 +942,15 @@ function makeStyles(c: ThemePalette) { fileInfo: { flex: 1, }, + fileNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, fileName: { fontSize: typography.body.fontSize, color: c.text, + flexShrink: 1, }, fileMetaRow: { flexDirection: 'row', @@ -886,10 +978,18 @@ function makeStyles(c: ThemePalette) { paddingHorizontal: spacing.xs, borderRadius: radius.md, }, + gridNameRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + maxWidth: '100%', + }, gridName: { fontSize: typography.caption.fontSize, color: c.text, textAlign: 'center', + flexShrink: 1, }, gridMeta: { fontSize: typography.small.fontSize, diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 8aa6f537..d8612538 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -7,6 +7,7 @@ vi.mock('../../api/calendar', () => ({ createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvents: vi.fn(), + setDefaultCalendar: vi.fn(), })); vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -37,6 +38,7 @@ const mockGetEvents = calendarApi.getEvents as ReturnType; const mockCreateEvent = calendarApi.createEvent as ReturnType; const mockUpdateEvent = calendarApi.updateEvent as ReturnType; const mockDeleteEvents = calendarApi.deleteEvents as ReturnType; +const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -110,7 +112,8 @@ describe('calendar-store', () => { await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); - expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31'); + // 4th arg is the owning account — undefined for primary-account calendars. + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); }); it('should skip refetch when loaded range covers requested', async () => { @@ -134,7 +137,7 @@ describe('calendar-store', () => { await useCalendarStore.getState().ensureRange('2026-04-01', '2026-04-30'); - expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-04-30'); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-04-30', undefined); }); it('should fetch calendars first when none are loaded', async () => { @@ -144,7 +147,7 @@ describe('calendar-store', () => { await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); expect(mockGetCalendars).toHaveBeenCalled(); - expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31'); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); }); }); @@ -273,8 +276,31 @@ describe('calendar-store', () => { ); // The third arg is the iMIP scheduling flag — undefined here since the - // event has no participants to notify. - expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined); + // event has no participants to notify. The fourth is the owning + // account — undefined for primary-account events. + expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, undefined); + }); + }); + + describe('setDefaultCalendar', () => { + it('flips isDefault to the chosen calendar within the same account', async () => { + useCalendarStore.setState({ + calendars: [ + { id: 'cal-1', name: 'A', isDefault: true } as any, + { id: 'cal-2', name: 'B' } as any, + { id: 'cal-3', name: 'Shared', isShared: true, accountId: 'acc-2', isDefault: true } as any, + ], + }); + mockSetDefaultCalendar.mockResolvedValue(undefined); + + await useCalendarStore.getState().setDefaultCalendar('cal-2'); + + expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', undefined); + const calendars = useCalendarStore.getState().calendars; + expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false); + expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true); + // Defaults in other accounts are untouched. + expect(calendars.find((c) => c.id === 'cal-3')?.isDefault).toBe(true); }); }); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index d7c5f727..0216d30b 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -12,6 +12,7 @@ import { batchCreateEvents as apiBatchCreateEvents, rsvpEvent as apiRsvpEvent, createCalendar as apiCreateCalendar, + setDefaultCalendar as apiSetDefaultCalendar, } from '../api/calendar'; import { jmapClient } from '../api/jmap-client'; import { expandRecurringEvents } from '../lib/recurrence-expansion'; @@ -57,6 +58,7 @@ export interface CalendarState { ) => Promise; importEvents: (events: Partial[], calendarId: string) => Promise; createCalendar: (name: string, color?: string) => Promise; + setDefaultCalendar: (id: string) => Promise; // Tasks createTask: (task: Partial, calendarId: string) => Promise; updateTask: (id: string, changes: Partial) => Promise; @@ -131,8 +133,31 @@ export const useCalendarStore = create()( if (!jmapClient.isConnected) return; set({ loading: true, error: null }); try { - const ids = (await queryEvents(calendarIds, after, before)) ?? []; - const raw = ids.length > 0 ? ((await fetchEvents(ids)) ?? []) : []; + // Group the requested calendars by owning account: the primary account + // (calendars without an accountId tag) plus one group per shared + // account, since CalendarEvent/query is scoped to a single account. + const calendars = get().calendars; + const groups = new Map(); + for (const id of calendarIds) { + const accountId = calendars.find((c) => c.id === id)?.accountId; + const group = groups.get(accountId); + if (group) group.push(id); + else groups.set(accountId, [id]); + } + if (groups.size === 0) groups.set(undefined, []); + + const raw: CalendarEvent[] = []; + for (const [accountId, ids] of groups) { + try { + const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; + if (eventIds.length === 0) continue; + const fetched = (await fetchEvents(eventIds, accountId)) ?? []; + raw.push(...(accountId ? fetched.map((e) => ({ ...e, accountId })) : fetched)); + } catch (err) { + // A failing shared account must not hide the user's own events. + if (!accountId) throw err; + } + } // Stalwart returns both Events and Tasks from CalendarEvent/query. Events // have a `start`; tasks are surfaced separately so they don't pollute the // grid (they're shown in the task list with their due date instead). @@ -176,12 +201,19 @@ export const useCalendarStore = create()( handleStateChange: async (change) => { if (!jmapClient.isConnected) return; - const accountId = jmapClient.accountId; - const accountChanges = change.changed?.[accountId]; - if (!accountChanges) return; - - const calendarChanged = 'Calendar' in accountChanges; - const eventChanged = 'CalendarEvent' in accountChanges; + // Watch the primary account plus every shared account we show calendars + // from, so edits the owner makes to a shared calendar refresh the view. + const known = new Set([jmapClient.accountId]); + for (const cal of get().calendars) { + if (cal.accountId) known.add(cal.accountId); + } + let calendarChanged = false; + let eventChanged = false; + for (const [accountId, types] of Object.entries(change.changed ?? {})) { + if (!known.has(accountId)) continue; + if ('Calendar' in types) calendarChanged = true; + if ('CalendarEvent' in types) eventChanged = true; + } if (!calendarChanged && !eventChanged) return; if (calendarChanged) { @@ -193,12 +225,15 @@ export const useCalendarStore = create()( }, createEvent: async (event, calendarId) => { + // Shared calendars live in the owner's account — route the create there. + const accountId = get().calendars.find((c) => c.id === calendarId)?.accountId; const created = await apiCreateEvent( event, calendarId, hasSchedulingParticipants(event) ? true : undefined, + accountId, ); - set({ events: [...get().events, created] }); + set({ events: [...get().events, accountId ? { ...created, accountId } : created] }); return created; }, @@ -210,7 +245,7 @@ export const useCalendarStore = create()( // carry participants. const schedule = hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent); - await apiUpdateEvent(realId, changes, schedule ? true : undefined); + await apiUpdateEvent(realId, changes, schedule ? true : undefined, storeEvent?.accountId); set({ events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)), }); @@ -222,6 +257,7 @@ export const useCalendarStore = create()( await apiDeleteEvents( [realId], hasSchedulingParticipants(storeEvent) ? true : undefined, + storeEvent?.accountId, ); set({ events: get().events.filter((e) => e.id !== id) }); }, @@ -232,7 +268,7 @@ export const useCalendarStore = create()( } const storeEvent = get().events.find((e) => e.id === eventId); const realId = storeEvent?.originalId || eventId; - await apiRsvpEvent(realId, participantId, status, replyTo); + await apiRsvpEvent(realId, participantId, status, replyTo, storeEvent?.accountId); set({ events: get().events.map((e) => { if (e.id !== eventId || !e.participants?.[participantId]) return e; @@ -273,15 +309,32 @@ export const useCalendarStore = create()( return created; }, + setDefaultCalendar: async (id) => { + const cal = get().calendars.find((c) => c.id === id); + await apiSetDefaultCalendar(id, cal?.accountId); + set({ + calendars: get().calendars.map((c) => { + if (c.id === id) return { ...c, isDefault: true }; + // Only one default per account — clear the flag on siblings within + // the same account scope. + if (c.isDefault && (c.accountId ?? null) === (cal?.accountId ?? null)) { + return { ...c, isDefault: false }; + } + return c; + }), + }); + }, + createTask: async (task, calendarId) => { - await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId); + const accountId = get().calendars.find((c) => c.id === calendarId)?.accountId; + await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId, undefined, accountId); await get().refresh(); }, updateTask: async (id, changes) => { const task = get().tasks.find((t) => t.id === id); const realId = task?.originalId || id; - await apiUpdateEvent(realId, changes); + await apiUpdateEvent(realId, changes, undefined, task?.accountId); set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) }); }, @@ -298,7 +351,7 @@ export const useCalendarStore = create()( deleteTask: async (id) => { const task = get().tasks.find((t) => t.id === id); const realId = task?.originalId || id; - await apiDeleteEvents([realId]); + await apiDeleteEvents([realId], undefined, task?.accountId); set({ tasks: get().tasks.filter((t) => t.id !== id) }); }, diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 79636100..a60911e9 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -169,6 +169,10 @@ interface PersistedSettings { showBirthdayCalendar: boolean; enableCalendarTasks: boolean; showTasksOnCalendar: boolean; + // Per-viewer color overrides for shared calendars, keyed by + // sharedCalendarColorKey(). Lets the user recolor calendars shared with + // them without changing the owner's color (parity with webmail #345). + sharedCalendarColors: Record; // Files filesFolderLayout: FilesFolderLayout; @@ -276,6 +280,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { showBirthdayCalendar: true, enableCalendarTasks: false, showTasksOnCalendar: true, + sharedCalendarColors: {}, filesFolderLayout: 'inline', filesDefaultViewMode: 'list', @@ -353,6 +358,10 @@ export interface SettingsState extends PersistedSettings { removeTrustedSender: (email: string) => void; isSenderTrusted: (email: string) => boolean; + // Shared-calendar color overrides + setSharedCalendarColor: (key: string, color: string) => void; + removeSharedCalendarColor: (key: string) => void; + // Sidebar apps addSidebarApp: (app: Omit) => void; updateSidebarApp: (id: string, updates: Partial>) => void; @@ -475,6 +484,17 @@ export const useSettingsStore = create((set, get) => ({ return get().trustedSenders.includes(normalized); }, + setSharedCalendarColor: (key, color) => { + set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } }); + persist(snapshot(get())); + }, + + removeSharedCalendarColor: (key) => { + const { [key]: _removed, ...rest } = get().sharedCalendarColors; + set({ sharedCalendarColors: rest }); + persist(snapshot(get())); + }, + addSidebarApp: (app) => { const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; set({ sidebarApps: [...get().sidebarApps, { ...app, id }] });