diff --git a/App.tsx b/App.tsx index 93d8edfc..00d68a83 100644 --- a/App.tsx +++ b/App.tsx @@ -31,6 +31,8 @@ import ContactDetailScreen from './src/screens/ContactDetailScreen'; import ContactFormScreen from './src/screens/ContactFormScreen'; import GroupDetailScreen from './src/screens/GroupDetailScreen'; import SettingsScreen from './src/screens/SettingsScreen'; +import ScheduledScreen from './src/screens/ScheduledScreen'; +import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen'; import { useAccountStore } from './src/stores/account-store'; import { useAuthStore } from './src/stores/auth-store'; import { useCalendarStore } from './src/stores/calendar-store'; @@ -449,6 +451,8 @@ export default function App() { }} /> + + ; @@ -117,4 +120,58 @@ describe('contacts operations', () => { expect(call[1].destroy).toEqual(['c1', 'c2']); }); }); + + describe('createAddressBook', () => { + it('creates a book and returns the created entity', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['AddressBook/set', { created: { 'new-book': { id: 'ab-9' } } }, '0']], + }); + + const book = await createAddressBook('Work'); + + const call = mockRequest.mock.calls[0][0][0]; + expect(call[1].create).toEqual({ 'new-book': { name: 'Work' } }); + expect(book).toEqual({ id: 'ab-9', name: 'Work' }); + }); + + it('throws when the server refuses to create', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['AddressBook/set', { notCreated: { 'new-book': { description: 'nope' } } }, '0']], + }); + await expect(createAddressBook('X')).rejects.toThrow('nope'); + }); + }); + + describe('updateAddressBook', () => { + it('forwards only settable properties', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']], + }); + + await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never); + + const call = mockRequest.mock.calls[0][0][0]; + expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } }); + }); + }); + + describe('deleteAddressBook', () => { + it('destroys the book by id', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']], + }); + + await deleteAddressBook('ab-1'); + + const call = mockRequest.mock.calls[0][0][0]; + expect(call[1].destroy).toEqual(['ab-1']); + }); + + it('throws when destroy fails', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['AddressBook/set', { notDestroyed: { 'ab-1': { description: 'in use' } } }, '0']], + }); + await expect(deleteAddressBook('ab-1')).rejects.toThrow('in use'); + }); + }); }); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 1587a1a4..ae699b15 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -8,8 +8,11 @@ const CALENDAR_EVENT_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', - 'participants', 'alerts', 'recurrenceRules', + 'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules', + 'replyTo', 'organizerCalendarAddress', 'sequence', + 'locations', 'virtualLocations', + 'progress', 'due', 'priority', 'percentComplete', 'links', 'created', 'updated', ]; @@ -69,38 +72,165 @@ export async function getEvents(ids: string[]): Promise { return all; } +// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation +// / reply / cancellation emails to participants. We pass it whenever an event +// has participants so creating/updating/deleting a meeting notifies attendees. +function setArgs( + accountId: string, + payload: Record, + sendSchedulingMessages?: boolean, +): Record { + const args: Record = { accountId, ...payload }; + if (sendSchedulingMessages !== undefined) { + args.sendSchedulingMessages = sendSchedulingMessages; + } + return args; +} + export async function createEvent( event: Partial, calendarId: string, + sendSchedulingMessages?: boolean, ): Promise { const accountId = jmapClient.accountId; const res = await jmapClient.request( - [['CalendarEvent/set', { - accountId, + [['CalendarEvent/set', setArgs(accountId, { create: { 'new-event': { ...event, calendarIds: { [calendarId]: true } }, }, - }, '0']], + }, sendSchedulingMessages), '0']], USING, ); - return res.methodResponses[0][1].created['new-event']; + const result = methodResult<{ + created?: Record; + notCreated?: Record; + }>(res); + const created = result.created?.['new-event']; + if (!created) { + const err = result.notCreated?.['new-event']; + throw new Error(err?.description || err?.type || 'Failed to create event'); + } + return created; +} + +// Batch-create many events in one CalendarEvent/set. Returns the number created. +export async function batchCreateEvents( + events: Partial[], + calendarId: string, +): Promise { + if (events.length === 0) return 0; + const accountId = jmapClient.accountId; + const create: Record> = {}; + events.forEach((e, i) => { + create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } }; + }); + const res = await jmapClient.request( + [['CalendarEvent/set', { accountId, create }, '0']], + USING, + ); + const result = methodResult<{ created?: Record }>(res); + return result.created ? Object.keys(result.created).length : 0; } export async function updateEvent( id: string, - changes: Partial, + changes: Partial | Record, + sendSchedulingMessages?: boolean, +): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']], + USING, + ); + const result = methodResult<{ notUpdated?: Record }>(res); + const err = result.notUpdated?.[id]; + if (err) throw new Error(err.description || err.type || 'Failed to update event'); +} + +export async function deleteEvents( + ids: string[], + sendSchedulingMessages?: boolean, ): Promise { const accountId = jmapClient.accountId; await jmapClient.request( - [['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']], + [['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']], USING, ); } -export async function deleteEvents(ids: string[]): Promise { +// RSVP to an invitation: patch the participant's participationStatus via a JSON +// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages). +export async function rsvpEvent( + eventId: string, + participantId: string, + status: 'accepted' | 'declined' | 'tentative', + replyTo?: Record | null, +): Promise { + const accountId = jmapClient.accountId; + // Escape per RFC 6901: ~ → ~0, / → ~1. + const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); + const patch: Record = { + [`participants/${escaped}/participationStatus`]: status, + }; + if (replyTo) patch.replyTo = replyTo; + const res = await jmapClient.request( + [['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']], + USING, + ); + const result = methodResult<{ notUpdated?: Record }>(res); + const err = result.notUpdated?.[eventId]; + if (err) throw new Error(err.description || err.type || 'Failed to send RSVP'); +} + +// Parse an uploaded .ics blob into one or more JSCalendar events (server-side). +export async function parseCalendarBlob(blobId: string): Promise[]> { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']], + USING, + ); + const result = methodResult<{ + parsed?: Record; + notParsable?: string[]; + notFound?: string[]; + }>(res); + if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format'); + if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found'); + const parsed = result.parsed?.[blobId]; + if (!parsed) return []; + return Array.isArray(parsed) ? parsed : [parsed]; +} + +export async function createCalendar( + name: string, + color?: string, +): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['Calendar/set', { + accountId, + create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } }, + }, '0']], + USING, + ); + const result = methodResult<{ + created?: Record; + notCreated?: Record; + }>(res); + const created = result.created?.['new-cal']; + if (!created) { + const err = result.notCreated?.['new-cal']; + throw new Error(err?.description || err?.type || 'Failed to create calendar'); + } + return created; +} + +// 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 { const accountId = jmapClient.accountId; await jmapClient.request( - [['CalendarEvent/set', { accountId, destroy: ids }, '0']], + [['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']], USING, ); } diff --git a/src/api/contacts.ts b/src/api/contacts.ts index f14d3467..0c5d95eb 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise { USING, ); } + +export async function createAddressBook(name: string): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']], + USING, + ); + const result = methodResult<{ + created?: Record; + notCreated?: Record; + }>(res); + const created = result.created?.['new-book']; + if (created) return { ...created, name }; + const err = result.notCreated?.['new-book']; + throw new Error(err?.description || err?.type || 'Failed to create address book'); +} + +export async function updateAddressBook( + id: string, + updates: Partial, +): Promise { + const accountId = jmapClient.accountId; + // Only forward server-settable properties. + const { name, description, sortOrder, isDefault } = updates as Record; + const patch: Record = {}; + if (name !== undefined) patch.name = name; + if (description !== undefined) patch.description = description; + if (sortOrder !== undefined) patch.sortOrder = sortOrder; + if (isDefault !== undefined) patch.isDefault = isDefault; + + const res = await jmapClient.request( + [['AddressBook/set', { accountId, update: { [id]: patch } }, '0']], + USING, + ); + const result = methodResult<{ notUpdated?: Record }>(res); + const err = result.notUpdated?.[id]; + if (err) throw new Error(err.description || err.type || 'Failed to update address book'); +} + +export async function deleteAddressBook(id: string): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['AddressBook/set', { accountId, destroy: [id] }, '0']], + USING, + ); + const result = methodResult<{ notDestroyed?: Record }>(res); + const err = result.notDestroyed?.[id]; + if (err) throw new Error(err.description || err.type || 'Failed to delete address book'); +} + +/** Fetch all contact cards that live in a specific address book. */ +export async function getContactsInBook(addressBookId: string): Promise { + const ids = await queryContacts({ inAddressBook: addressBookId }); + return getContacts(ids); +} diff --git a/src/api/email.ts b/src/api/email.ts index 2624748b..548aa89e 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -358,6 +358,50 @@ export async function moveEmail( ]); } +// Move several emails from one mailbox to another in a single Email/set. +export async function moveEmails( + ids: string[], + fromMailboxId: string, + toMailboxId: string, +): Promise { + if (ids.length === 0) return; + const accountId = jmapClient.accountId; + const update: Record> = {}; + for (const id of ids) { + update[id] = { + [`mailboxIds/${fromMailboxId}`]: null, + [`mailboxIds/${toMailboxId}`]: true, + }; + } + await jmapClient.request([['Email/set', { accountId, update }, '0']]); +} + +// Batch delete: destroy outright when already in trash, otherwise move to trash. +export async function deleteEmails( + ids: string[], + trashMailboxId: string, + currentMailboxId: string, +): Promise { + if (ids.length === 0) return; + if (currentMailboxId === trashMailboxId) { + const accountId = jmapClient.accountId; + await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]); + } else { + await moveEmails(ids, currentMailboxId, trashMailboxId); + } +} + +// Apply keyword maps to several emails in one round-trip. +export async function setKeywordsForEmails( + updates: Array<{ id: string; keywords: Record }>, +): Promise { + if (updates.length === 0) return; + const accountId = jmapClient.accountId; + const update: Record }> = {}; + for (const u of updates) update[u.id] = { keywords: u.keywords }; + await jmapClient.request([['Email/set', { accountId, update }, '0']]); +} + // Restore each email's mailboxIds to the snapshot supplied. Used by undo to // reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces // the entire map, so we don't need to compute a diff against the current state. @@ -558,6 +602,15 @@ export interface OutgoingAttachment { cid?: string; } +export interface SendEmailResult { + /** True when the message was deferred (HOLDFOR / FUTURERELEASE). */ + scheduled: boolean; + /** ISO timestamp the server resolved for a deferred send, when known. */ + sendAt?: string; + emailId?: string; + emailSubmissionId?: string; +} + export async function sendEmail( email: { from: EmailAddress[]; @@ -573,7 +626,11 @@ export async function sendEmail( }, identityId: string, sentMailboxId: string, -): Promise { + // When > 0 the message is held for this many seconds before delivery via the + // SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later" + // scheduling and the global send-delay (undo-send) window. + holdForSeconds?: number, +): Promise { const accountId = jmapClient.accountId; const emailCreate: Record = { from: email.from, @@ -615,14 +672,164 @@ export async function sendEmail( emailCreate['header:References:asText'] = email.references ?? email.inReplyTo; } - await jmapClient.request( + const submissionCreate: Record = { emailId: '#draft', identityId }; + // For a deferred send the envelope must be set explicitly so the HOLDFOR + // mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the + // server derive mailFrom from the Identity, dropping our parameter). + if (holdForSeconds && holdForSeconds > 0) { + const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])] + .map((r) => r.email.trim()) + .filter(Boolean) + .map((address) => ({ email: address })); + submissionCreate.envelope = { + mailFrom: { + email: email.from[0]?.email, + parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) }, + }, + rcptTo, + }; + } + + const res = await jmapClient.request( [ ['Email/set', { accountId, create: { draft: emailCreate } }, '0'], ['EmailSubmission/set', { accountId, - create: { 'sub-1': { emailId: '#draft', identityId } }, + create: { 'sub-1': submissionCreate }, }, '1'], ], [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION], ); + + let emailId: string | undefined; + let emailSubmissionId: string | undefined; + let sendAt: string | undefined; + for (const [methodName, result] of res.methodResponses) { + if (methodName.endsWith('/error')) { + throw new Error((result as { description?: string }).description ?? 'Send failed'); + } + if (methodName === 'Email/set') { + const notCreated = (result as { notCreated?: Record }).notCreated?.draft; + if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message'); + emailId = (result as { created?: Record }).created?.draft?.id; + } + if (methodName === 'EmailSubmission/set') { + const notCreated = (result as { notCreated?: Record }).notCreated?.['sub-1']; + if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message'); + const created = (result as { created?: Record }).created?.['sub-1']; + emailSubmissionId = created?.id; + sendAt = created?.sendAt; + } + } + + return { + scheduled: !!(holdForSeconds && holdForSeconds > 0), + sendAt, + emailId, + emailSubmissionId, + }; +} + +export interface ScheduledEmail { + emailSubmissionId: string; + emailId: string; + identityId: string; + threadId?: string; + sendAt: string; + undoStatus?: string; + subject?: string; + to?: EmailAddress[]; + from?: EmailAddress[]; + preview?: string; +} + +// List pending (not-yet-delivered, not cancelled) scheduled submissions whose +// send time is still in the future, joined with a light Email/get so the UI +// can show subject/recipients. Empty when the server lacks FUTURERELEASE. +export async function listScheduledEmails(): Promise { + if (!jmapClient.hasDelayedSend()) return []; + const accountId = jmapClient.accountId; + const now = Date.now(); + + const queryRes = await jmapClient.request( + [['EmailSubmission/query', { accountId, limit: 200 }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + ); + const [queryName, queryBody] = queryRes.methodResponses[0]; + if (queryName.endsWith('/error')) return []; + const ids = (queryBody.ids as string[]) ?? []; + if (ids.length === 0) return []; + + const subRes = await jmapClient.request( + [['EmailSubmission/get', { + accountId, + ids, + properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'], + }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + ); + const submissions = ((subRes.methodResponses[0][1].list as Array<{ + id: string; + emailId: string; + identityId: string; + threadId?: string; + sendAt?: string; + undoStatus?: string; + }>) ?? []).filter((s) => { + if (s.undoStatus !== 'pending' || !s.sendAt) return false; + const t = new Date(s.sendAt).getTime(); + return Number.isFinite(t) && t > now; + }); + if (submissions.length === 0) return []; + + const emailIds = Array.from(new Set(submissions.map((s) => s.emailId))); + const emailRes = await jmapClient.request([ + ['Email/get', { + accountId, + ids: emailIds, + properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'], + }, '0'], + ]); + const emailById = new Map( + ((emailRes.methodResponses[0][1].list as Email[]) ?? []).map((e) => [e.id, e]), + ); + + return submissions + .map((s): ScheduledEmail | null => { + const e = emailById.get(s.emailId); + if (!s.sendAt) return null; + return { + emailSubmissionId: s.id, + emailId: s.emailId, + identityId: s.identityId, + threadId: s.threadId ?? e?.threadId, + sendAt: s.sendAt, + undoStatus: s.undoStatus, + subject: e?.subject, + to: e?.to, + from: e?.from, + preview: e?.preview, + }; + }) + .filter((s): s is ScheduledEmail => s !== null) + .sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()); +} + +// Cancel a pending scheduled send. The held message copy stays in Sent; only +// delivery is stopped (matches the webmail behaviour). +export async function cancelScheduledSend(emailSubmissionId: string): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['EmailSubmission/set', { + accountId, + update: { [emailSubmissionId]: { undoStatus: 'canceled' } }, + }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + ); + const failure = res.methodResponses[0][1].notUpdated?.[emailSubmissionId] as + | { type?: string; description?: string } + | undefined; + if (failure) { + throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send'); + } } diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 6c7e8d3f..67d7de8b 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -451,6 +451,59 @@ export class JMAPClient { return core?.maxObjectsInGet || 500; } + // ── Scheduled send (FUTURERELEASE) ──────────────────── + // The JMAP submission capability advertises `maxDelayedSend` (max hold in + // seconds) and a `submissionExtensions` map; FUTURERELEASE support is what + // lets us defer delivery via the SMTP HOLDFOR parameter. Mirrors the webmail + // implementation so behaviour stays in sync across platforms. + + private get submissionCapability(): + | { maxDelayedSend?: number; submissionExtensions?: unknown } + | undefined { + return this.session?.capabilities?.[CAPABILITIES.SUBMISSION] as + | { maxDelayedSend?: number; submissionExtensions?: unknown } + | undefined; + } + + getMaxDelayedSend(): number { + const max = this.submissionCapability?.maxDelayedSend; + return typeof max === 'number' ? max : 0; + } + + hasDelayedSend(): boolean { + const cap = this.submissionCapability; + if (!cap) return false; + const ext = cap.submissionExtensions; + // submissionExtensions is a map of extension name → params. FUTURERELEASE + // (RFC 4865) is the SMTP extension that backs deferred delivery. + const hasFutureRelease = + !!ext && + typeof ext === 'object' && + Object.keys(ext as Record).some( + (k) => k.toUpperCase() === 'FUTURERELEASE', + ); + return hasFutureRelease && this.getMaxDelayedSend() > 0; + } + + // ── Stored credentials (per-account) ────────────────── + // Exposed so the unified-inbox aggregator can read another account's + // credentials without disturbing this client's live session, and persist a + // refreshed OAuth token back to secure storage. + + async getStoredCredentials(accountId: string): Promise { + const stored = await SecureStore.getItemAsync(credentialsKey(accountId)); + if (!stored) return null; + try { + return JSON.parse(stored) as StoredCredentials; + } catch { + return null; + } + } + + async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise { + await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds)); + } + // ── Blob Download ───────────────────────────────────── // Expand the RFC 6570 level-1 template the JMAP server advertises. diff --git a/src/api/types.ts b/src/api/types.ts index b1b085e1..0845c261 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -288,6 +288,13 @@ export interface ContactCryptoKey { contexts?: Record; } +export interface ContactDirectory { + uri: string; + kind?: 'directory' | 'entry' | string; + mediaType?: string; + pref?: number; +} + export interface ContactPronouns { pronouns: string; pref?: number; @@ -322,12 +329,15 @@ export interface ContactCard { relatedTo?: Record; links?: Record; cryptoKeys?: Record; + directories?: Record; keywords?: Record; members?: Record; speakToAs?: ContactSpeakToAs; calendarUri?: string; schedulingUri?: string; freeBusyUri?: string; + source?: string; + prodId?: string; created?: string; updated?: string; } @@ -356,13 +366,39 @@ export interface AddressBookWithCount extends AddressBook { // ─── Calendar (RFC 8984 JSCalendar) ───────────────────── export interface Participant { + '@type'?: 'Participant'; name?: string; email?: string; sendTo?: Record; + // Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo. + calendarAddress?: string; kind?: string; roles?: Record; - participationStatus?: string; + participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string; + participationComment?: string; + scheduleStatus?: string[]; expectReply?: boolean; + description?: string; +} + +// JSCalendar Location (RFC 8984 §4.2.5) — a physical place. +export interface EventLocation { + '@type'?: 'Location'; + name?: string; + description?: string; + locationTypes?: Record; + relativeTo?: string; + timeZone?: string; + coordinates?: string; +} + +// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place. +export interface VirtualLocation { + '@type'?: 'VirtualLocation'; + name?: string; + description?: string; + uri: string; + features?: Record; } export interface RecurrenceRule { @@ -403,27 +439,56 @@ export interface CalendarEvent { status?: string; freeBusyStatus?: string; participants?: Record; + // Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go; + // Stalwart additionally exposes the organizer as `organizerCalendarAddress`. + replyTo?: Record; + organizerCalendarAddress?: string; + sequence?: number; recurrenceRules?: RecurrenceRule[]; recurrenceOverrides?: Record>; excludedRecurrenceRules?: RecurrenceRule[]; recurrenceId?: string; originalId?: string; + useDefaultAlerts?: boolean; alerts?: Record; + locations?: Record; + virtualLocations?: Record; links?: Record; created?: string; updated?: string; - progress?: string; + // Task-only (RFC 8984 JSTask) fields. + progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string; due?: string; + priority?: number; + percentComplete?: number; color?: string; + // Client-only: account the event/occurrence belongs to (for shared/virtual). + localAccountId?: string; +} + +export interface CalendarRights { + mayReadFreeBusy?: boolean; + mayReadItems?: boolean; + mayWriteAll?: boolean; + mayWriteOwn?: boolean; + mayUpdatePrivate?: boolean; + mayRSVP?: boolean; + mayShare?: boolean; + mayDelete?: boolean; + // Legacy short flag still read by the sidebar drawer. + mayWrite?: boolean; } export interface Calendar { id: string; name: string; + description?: string | null; color?: string; isVisible?: boolean; + isSubscribed?: boolean; + isDefault?: boolean; sortOrder?: number; - myRights?: Record; + myRights?: CalendarRights; } // ─── Files (FileNode) ─────────────────────────────────── diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts new file mode 100644 index 00000000..1da5b398 --- /dev/null +++ b/src/api/unified-inbox.ts @@ -0,0 +1,196 @@ +import { CAPABILITIES } from './types'; +import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types'; +import { jmapClient, type StoredCredentials } from './jmap-client'; +import { secureFetch } from '../lib/client-cert'; +import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth'; + +// Aggregated inbox across every logged-in account. Because the JMAP client is +// a single-account singleton (one live session at a time), we fetch each +// account's inbox "detached" here — reading its stored credentials directly and +// issuing requests against its own API URL — so the active account's live +// session is never disturbed. + +const EMAIL_LIST_PROPERTIES = [ + 'id', 'threadId', 'mailboxIds', 'keywords', 'size', + 'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment', +]; + +const TOKEN_REFRESH_LEEWAY_MS = 60_000; + +export interface UnifiedEmail extends Email { + /** Registry account id this message belongs to (not the JMAP account id). */ + sourceAccountId: string; +} + +export interface UnifiedInboxResult { + emails: UnifiedEmail[]; + /** accountId → error message for accounts that could not be fetched. */ + errors: Record; +} + +function authHeaderFor(creds: StoredCredentials): string { + if (creds.accessToken) return `Bearer ${creds.accessToken}`; + return `Basic ${btoa(`${creds.username}:${creds.password}`)}`; +} + +function originOf(url: string): string | null { + const m = url.match(/^(https?:\/\/[^/?#]+)/i); + return m ? m[1] : null; +} + +// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at +// the origin we actually connected to (servers often self-report unreachable +// container-internal hosts). +function rewriteApiUrl(session: JMAPSession, serverUrl: string): string { + const serverOrigin = originOf(serverUrl); + const apiOrigin = originOf(session.apiUrl); + if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl; + return serverOrigin + session.apiUrl.slice(apiOrigin.length); +} + +function resolveJmapAccountId(session: JMAPSession): string | null { + return ( + session.primaryAccounts?.[CAPABILITIES.MAIL] || + session.primaryAccounts?.[CAPABILITIES.CORE] || + Object.keys(session.accounts ?? {})[0] || + null + ); +} + +// Refresh an about-to-expire OAuth token and persist it so the next fetch (and +// a later account switch) reuse the fresh one. Returns possibly-updated creds. +async function ensureFreshCredentials( + accountId: string, + creds: StoredCredentials, +): Promise { + if ( + !creds.accessToken || + !creds.refreshToken || + !creds.tokenEndpoint || + !creds.clientId || + creds.expiresAt == null + ) { + return creds; + } + if (creds.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return creds; + try { + const tokens: OAuthTokens = { + accessToken: creds.accessToken, + refreshToken: creds.refreshToken, + expiresAt: creds.expiresAt, + tokenEndpoint: creds.tokenEndpoint, + clientId: creds.clientId, + }; + const next = await refreshOAuthAccessToken(tokens); + const updated: StoredCredentials = { + ...creds, + accessToken: next.accessToken, + refreshToken: next.refreshToken ?? creds.refreshToken, + expiresAt: next.expiresAt, + tokenEndpoint: next.tokenEndpoint, + clientId: next.clientId, + }; + await jmapClient.setStoredCredentials(accountId, updated); + return updated; + } catch { + // Fall back to the existing (possibly expired) token; the request will + // surface a 401 which we report as an error for this account. + return creds; + } +} + +async function jmapPost( + apiUrl: string, + authHeader: string, + methodCalls: JMAPMethodCall[], + using: string[], +): Promise, string]>> { + const response = await secureFetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + body: JSON.stringify({ using, methodCalls }), + }); + if (response.status === 401) throw new Error('Session expired'); + if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`); + const body = await response.json(); + return body.methodResponses ?? []; +} + +async function fetchInboxForAccount( + accountId: string, + limit: number, +): Promise { + let creds = await jmapClient.getStoredCredentials(accountId); + if (!creds) throw new Error('No stored credentials'); + creds = await ensureFreshCredentials(accountId, creds); + + const baseUrl = creds.serverUrl.replace(/\/+$/, ''); + const authHeader = authHeaderFor(creds); + + // Session discovery. + const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, { + headers: { Authorization: authHeader, Accept: 'application/json' }, + }); + if (sessionRes.status === 401) throw new Error('Session expired'); + if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`); + const session = (await sessionRes.json()) as JMAPSession; + const apiUrl = rewriteApiUrl(session, baseUrl); + const jmapAccountId = resolveJmapAccountId(session); + if (!jmapAccountId) throw new Error('No mail account in session'); + + // Find the inbox mailbox. + const mailboxResponses = await jmapPost( + apiUrl, + authHeader, + [['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.MAIL], + ); + const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? []; + const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0]; + if (!inbox) return []; + + // Query + fetch the most recent messages. + const queryResponses = await jmapPost( + apiUrl, + authHeader, + [['Email/query', { + accountId: jmapAccountId, + filter: { inMailbox: inbox.id }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.MAIL], + ); + const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? []; + if (ids.length === 0) return []; + + const getResponses = await jmapPost( + apiUrl, + authHeader, + [['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.MAIL], + ); + const list = (getResponses[0]?.[1]?.list as Email[]) ?? []; + return list.map((e) => ({ ...e, sourceAccountId: accountId })); +} + +export async function fetchUnifiedInbox( + accountIds: string[], + perAccountLimit = 25, +): Promise { + const errors: Record = {}; + const settled = await Promise.all( + accountIds.map(async (id) => { + try { + return await fetchInboxForAccount(id, perAccountLimit); + } catch (err) { + errors[id] = err instanceof Error ? err.message : 'Failed to load'; + return [] as UnifiedEmail[]; + } + }), + ); + const emails = settled + .flat() + .sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); + return { emails, errors }; +} diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 3d0006ae..72a3646d 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -140,7 +140,18 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments); const contacts = useContactsStore((s) => s.contacts); + const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); + const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); + const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook); + const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook); const resolvedTheme = useResolvedTheme(); + + // Passively load the dedicated "Trusted Senders" address book once so its + // entries (synced across devices) feed the external-content trust check. We + // do not create the book here — it is created lazily when a sender is trusted. + React.useEffect(() => { + if (!trustedSendersLoaded) void loadTrustedSendersBook(false); + }, [trustedSendersLoaded, loadTrustedSendersBook]); // Index the address book once per render so the trusted-sender check below // is O(1) per email. Only used when the matching setting is on. const addressBookEmails = React.useMemo(() => { @@ -170,6 +181,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false; const trusted = senderEmail ? isSenderTrusted(senderEmail) + || trustedSenderEmails.includes(senderEmail.toLowerCase()) || (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false) : false; @@ -270,7 +282,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps const onLoadImages = () => setAllowOnce(true); const onTrustSender = () => { if (senderEmail) { + // Keep the local allow-list for instant effect, and persist to the + // dedicated "Trusted Senders" address book so the trust syncs across + // devices (matches the webmail behavior). addTrustedSender(senderEmail); + void addToTrustedSendersBook(senderEmail).catch(() => {}); setAllowOnce(true); } }; diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 5daded3d..0daf26e7 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -8,6 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star, Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus, + Clock, Layers, } from 'lucide-react-native'; import type { LucideIcon } from 'lucide-react-native'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; @@ -427,6 +428,24 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) )} + {/* Quick views */} + {accounts.length > 1 && ( + [styles.quickRow, pressed && styles.rowPressed]} + onPress={() => { onClose(); navigation.navigate('UnifiedInbox'); }} + > + + All inboxes + + )} + [styles.quickRow, pressed && styles.rowPressed]} + onPress={() => { onClose(); navigation.navigate('Scheduled'); }} + > + + Scheduled + + {/* Folders section */} {foldersExpanded ? ( @@ -667,6 +686,17 @@ function makeStyles(c: ThemePalette) { }, sectionSettings: { padding: 4 }, + quickRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + paddingHorizontal: spacing.md, + borderLeftWidth: 2, + borderLeftColor: 'transparent', + }, + quickRowLabel: { ...typography.body, color: c.text }, + empty: { ...typography.body, color: c.textMuted, diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx new file mode 100644 index 00000000..079c9caf --- /dev/null +++ b/src/components/TagSheet.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { X, Check, Tag } from 'lucide-react-native'; +import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import { useSheetDrag } from '../lib/use-sheet-drag'; +import { keywordToken, type KeywordDef } from '../stores/keywords-store'; +import type { Email } from '../api/types'; + +interface TagSheetProps { + visible: boolean; + onClose: () => void; + keywords: KeywordDef[]; + /** The currently-selected emails, used to show which tags are already set. */ + selectedEmails: Email[]; + /** Toggle a keyword token on/off across the whole selection. */ + onToggle: (token: string, on: boolean) => void; +} + +export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const slideY = React.useRef(new Animated.Value(500)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); + + React.useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + // A tag counts as "applied" only when every selected email carries it; in + // that case tapping removes it, otherwise tapping adds it to all. + const allHaveToken = React.useCallback( + (token: string) => + selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.[token]), + [selectedEmails], + ); + + return ( + + + + + + + + + + + Tag {selectedEmails.length} selected + + + + + + + {keywords.length === 0 ? ( + No tags defined. Add tags in Settings. + ) : ( + keywords.map((kw) => { + const token = keywordToken(kw.id); + const applied = allHaveToken(token); + const dot = tokenColors.tags[kw.color]?.dot ?? c.primary; + return ( + onToggle(token, !applied)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {kw.label} + {applied && } + + ); + }) + )} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + overlayPress: { flex: 1 }, + sheet: { + position: 'absolute', + left: 0, right: 0, bottom: 0, + maxHeight: '75%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + }, + handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm }, + handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + title: { ...typography.bodySemibold, color: c.text }, + close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs }, + empty: { ...typography.body, color: c.textMuted, padding: spacing.lg, textAlign: 'center' }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + minHeight: 44, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + rowLabel: { ...typography.body, color: c.text, flex: 1 }, + }); +} diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index bb552899..72ed0ca3 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -11,7 +11,7 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { Check, X } from 'lucide-react-native'; +import { Check, X, Upload, Rss } from 'lucide-react-native'; import type { Calendar } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -24,6 +24,8 @@ interface CalendarSidebarDrawerProps { hiddenCalendarIds: string[]; onToggle: (id: string) => void; onClose: () => void; + onImport?: () => void; + onManageSubscriptions?: () => void; } export function CalendarSidebarDrawer({ @@ -32,6 +34,8 @@ export function CalendarSidebarDrawer({ hiddenCalendarIds, onToggle, onClose, + onImport, + onManageSubscriptions, }: CalendarSidebarDrawerProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -122,6 +126,29 @@ export function CalendarSidebarDrawer({ onToggle={onToggle} /> )} + + {(onImport || onManageSubscriptions) && ( + + {onManageSubscriptions && ( + [styles.actionRow, pressed && styles.rowPressed]} + > + + Subscriptions + + )} + {onImport && ( + [styles.actionRow, pressed && styles.rowPressed]} + > + + Import from file + + )} + + )} @@ -251,5 +278,21 @@ function makeStyles(c: ThemePalette) { }, rowName: { flex: 1, ...typography.body, color: c.text }, rowNameMuted: { color: c.textMuted }, + + actionsSection: { + marginTop: spacing.lg, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: c.border, + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + minHeight: 44, + }, + actionText: { ...typography.body, color: c.text }, }); } diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index f0a92cf2..ee2c2503 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -3,6 +3,7 @@ import { Animated, Dimensions, Easing, + Linking, Modal, Pressable, ScrollView, @@ -15,13 +16,16 @@ import { AlignLeft, Bell, Calendar as CalendarIcon, + Check, Clock, Copy, + HelpCircle, MapPin, Pencil, Repeat, Trash2, Users, + Video, X, } from 'lucide-react-native'; import { format } from 'date-fns'; @@ -33,8 +37,16 @@ import { getEventColor, getPrimaryCalendarId, } from '../../lib/calendar-utils'; +import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts'; +import { + findParticipantByEmail, + isOrganizerParticipant, +} from '../../lib/calendar-invitation'; +import { useAccountStore } from '../../stores/account-store'; import { useSheetDrag } from '../../lib/use-sheet-drag'; +type RsvpStatus = 'accepted' | 'declined' | 'tentative'; + interface EventDetailSheetProps { event: CalendarEvent | null; calendars: Calendar[]; @@ -42,6 +54,7 @@ interface EventDetailSheetProps { onEdit?: (event: CalendarEvent) => void; onDelete?: (event: CalendarEvent) => void; onDuplicate?: (event: CalendarEvent) => void; + onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise; } function formatRange(event: CalendarEvent): string { @@ -84,22 +97,6 @@ function recurrenceLabel(event: CalendarEvent): string | null { return label; } -function alertLabel(event: CalendarEvent): string | null { - if (!event.alerts) return null; - const first = Object.values(event.alerts)[0]; - if (!first) return null; - const offset = first.trigger?.offset; - if (!offset) return null; - if (offset === 'PT0S' || offset === '-PT0S') return 'At time of event'; - const min = offset.match(/-?PT(\d+)M$/); - if (min) return `${min[1]} minutes before`; - const hr = offset.match(/-?PT(\d+)H$/); - if (hr) return `${hr[1]} hours before`; - const day = offset.match(/-?P(\d+)D/); - if (day) return `${day[1]} days before`; - return null; -} - export function EventDetailSheet({ event, calendars, @@ -107,8 +104,11 @@ export function EventDetailSheet({ onEdit, onDelete, onDuplicate, + onRsvp, }: EventDetailSheetProps) { const c = useColors(); + const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null); + const [rsvpBusy, setRsvpBusy] = React.useState(false); const styles = React.useMemo(() => makeStyles(c), [c]); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; @@ -158,8 +158,26 @@ export function EventDetailSheet({ const calendar = calendars.find((c) => c.id === calendarId); const range = formatRange(event); const recurrence = recurrenceLabel(event); - const alert = alertLabel(event); + const reminders = alertsToReminders(event.alerts); const participants = event.participants ? Object.values(event.participants) : []; + const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; + const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; + + // Can the signed-in user RSVP? Only when they appear as a non-organizer + // participant and the caller wired an onRsvp handler. + const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null; + const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant)); + const myStatus = me?.participant.participationStatus; + + const doRsvp = async (status: RsvpStatus) => { + if (!onRsvp || !me || rsvpBusy) return; + setRsvpBusy(true); + try { + await onRsvp(event, me.id, status); + } finally { + setRsvpBusy(false); + } + }; return ( } text={range} /> + {location ? ( + } text={location} /> + ) : null} + {videoUri ? ( + + + + { void Linking.openURL(videoUri); }} + > + Join video call + + + ) : null} {event.description ? ( } @@ -211,10 +245,10 @@ export function EventDetailSheet({ text={recurrence} /> )} - {alert && ( + {reminders.length > 0 && ( } - text={alert} + text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')} /> )} {calendar && ( @@ -224,6 +258,38 @@ export function EventDetailSheet({ /> )} + {canRsvp && ( + + Going? + + } + active={myStatus === 'accepted'} + activeColor={c.success} + disabled={rsvpBusy} + onPress={() => doRsvp('accepted')} + /> + } + active={myStatus === 'tentative'} + activeColor={c.warning} + disabled={rsvpBusy} + onPress={() => doRsvp('tentative')} + /> + } + active={myStatus === 'declined'} + activeColor={c.error} + disabled={rsvpBusy} + onPress={() => doRsvp('declined')} + /> + + + )} + {participants.length > 0 && ( @@ -334,6 +400,39 @@ function ActionButton({ ); } +function RsvpButton({ + label, + icon, + active, + activeColor, + disabled, + onPress, +}: { + label: string; + icon: React.ReactNode; + active: boolean; + activeColor: string; + disabled?: boolean; + onPress: () => void; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + return ( + + {icon} + {label} + + ); +} + function statusColor(c: ThemePalette, status?: string): string { switch (status) { case 'accepted': @@ -417,6 +516,33 @@ function makeStyles(c: ThemePalette) { detailIcon: { width: 16, paddingTop: 2 }, detailText: { flex: 1, ...typography.body, color: c.text }, + joinBtn: { + flex: 1, + alignSelf: 'flex-start', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radius.sm, + backgroundColor: c.primaryBg, + }, + joinBtnText: { ...typography.bodyMedium, color: c.primary }, + + rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs }, + rsvpPrompt: { ...typography.bodyMedium, color: c.text }, + rsvpButtons: { flexDirection: 'row', gap: spacing.sm }, + rsvpBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + rsvpBtnText: { ...typography.caption, color: c.text }, + participantsBlock: { marginTop: spacing.sm, gap: spacing.xs, diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index d484d9d4..a4fa44b9 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -17,13 +17,22 @@ import { Calendar as CalendarIcon, Clock, MapPin, + Video, Repeat, Bell, + Plus, Users, Trash2, + AlignLeft, } from 'lucide-react-native'; import { addHours, addMinutes, format } from 'date-fns'; -import type { Calendar, CalendarEvent, Participant } from '../../api/types'; +import type { + Calendar, + CalendarEvent, + Participant, + EventLocation, + VirtualLocation, +} from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { @@ -33,11 +42,17 @@ import { getEventStartDate, getPrimaryCalendarId, } from '../../lib/calendar-utils'; +import { + alertsToReminders, + remindersToAlerts, + formatReminder, + REMINDER_PRESETS, + type Reminder, +} from '../../lib/calendar-alerts'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; -type AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440'; const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [ { value: 'none', label: 'Does not repeat' }, @@ -47,16 +62,6 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [ { value: 'yearly', label: 'Yearly' }, ]; -const ALERT_OPTIONS: { value: AlertOption; label: string }[] = [ - { value: 'none', label: 'No reminder' }, - { value: 'at_time', label: 'At time of event' }, - { value: '5', label: '5 minutes before' }, - { value: '15', label: '15 minutes before' }, - { value: '30', label: '30 minutes before' }, - { value: '60', label: '1 hour before' }, - { value: '1440', label: '1 day before' }, -]; - interface EventModalProps { visible: boolean; event?: CalendarEvent | null; @@ -101,39 +106,30 @@ function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOp return 'none'; } -function detectAlert(event: CalendarEvent | null | undefined): AlertOption { - const alerts = event?.alerts; - if (!alerts) return 'none'; - const first = Object.values(alerts)[0]; - if (!first) return 'none'; - const offset = first.trigger?.offset; - if (!offset) return 'none'; - if (offset === 'PT0S' || offset === '-PT0S') return 'at_time'; - const min = offset.match(/-?PT(\d+)M$/); - if (min) { - const v = min[1]; - if (v === '5' || v === '15' || v === '30') return v as AlertOption; - } - const hr = offset.match(/-?PT(\d+)H$/); - if (hr && hr[1] === '1') return '60'; - const day = offset.match(/-?P(\d+)D/); - if (day && day[1] === '1') return '1440'; - return 'none'; +function detectLocation(event: CalendarEvent | null | undefined): string { + const locations = event?.locations; + if (!locations) return ''; + const first = Object.values(locations)[0]; + return first?.name || ''; } -function alertFromOption(opt: AlertOption): CalendarEvent['alerts'] | undefined { - if (opt === 'none') return undefined; - let offset = '-PT0M'; - if (opt === 'at_time') offset = 'PT0S'; - else if (opt === '60') offset = '-PT1H'; - else if (opt === '1440') offset = '-P1D'; - else offset = `-PT${opt}M`; - return { - 'a-1': { - trigger: { '@type': 'OffsetTrigger', offset }, - action: 'display', - }, - }; +function detectVideoUrl(event: CalendarEvent | null | undefined): string { + const v = event?.virtualLocations; + if (!v) return ''; + const first = Object.values(v)[0]; + return first?.uri || ''; +} + +function buildLocations(name: string): Record | undefined { + const trimmed = name.trim(); + if (!trimmed) return undefined; + return { 'loc-1': { '@type': 'Location', name: trimmed } }; +} + +function buildVirtualLocations(uri: string): Record | undefined { + const trimmed = uri.trim(); + if (!trimmed) return undefined; + return { 'vloc-1': { '@type': 'VirtualLocation', name: 'Video call', uri: trimmed } }; } function recurrenceFromOption(opt: RecurrenceOption): CalendarEvent['recurrenceRules'] | undefined { @@ -163,9 +159,11 @@ export function EventModal({ const [calendarId, setCalendarId] = React.useState(defaultCalendarId || calendars[0]?.id || ''); const [participants, setParticipants] = React.useState>({}); const [recurrence, setRecurrence] = React.useState('none'); - const [alert, setAlert] = React.useState('none'); + const [reminders, setReminders] = React.useState([]); + const [location, setLocation] = React.useState(''); + const [videoUrl, setVideoUrl] = React.useState(''); const [recurrenceOpen, setRecurrenceOpen] = React.useState(false); - const [alertOpen, setAlertOpen] = React.useState(false); + const [reminderPickerOpen, setReminderPickerOpen] = React.useState(false); const [calendarOpen, setCalendarOpen] = React.useState(false); const [showStartDate, setShowStartDate] = React.useState(false); const [showStartTime, setShowStartTime] = React.useState(false); @@ -184,7 +182,9 @@ export function EventModal({ setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || ''); setParticipants(event.participants || {}); setRecurrence(detectRecurrence(event)); - setAlert(detectAlert(event)); + setReminders(alertsToReminders(event.alerts)); + setLocation(detectLocation(event)); + setVideoUrl(detectVideoUrl(event)); } else { const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date()); setTitle(''); @@ -195,7 +195,9 @@ export function EventModal({ setCalendarId(defaultCalendarId || calendars[0]?.id || ''); setParticipants({}); setRecurrence('none'); - setAlert('none'); + setReminders([]); + setLocation(''); + setVideoUrl(''); } }, [visible, event, defaultDate, defaultCalendarId, calendars]); @@ -240,7 +242,10 @@ export function EventModal({ duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end), participants: Object.keys(participants).length > 0 ? participants : undefined, recurrenceRules: recurrenceFromOption(recurrence), - alerts: alertFromOption(alert), + alerts: remindersToAlerts(reminders), + useDefaultAlerts: reminders.length > 0 ? false : undefined, + locations: buildLocations(location), + virtualLocations: buildVirtualLocations(videoUrl), }; await onSave(data, calendarId); onClose(); @@ -255,7 +260,21 @@ export function EventModal({ const selectedCalendar = calendars.find((c) => c.id === calendarId); const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None'; - const alertLabel = ALERT_OPTIONS.find((o) => o.value === alert)?.label || 'None'; + + const addReminder = (minutesBefore: number) => { + setReminderPickerOpen(false); + setReminders((prev) => + prev.some((r) => r.minutesBefore === minutesBefore) + ? prev + : [...prev, { minutesBefore }].sort((a, b) => b.minutesBefore - a.minutesBefore), + ); + }; + const removeReminder = (minutesBefore: number) => { + setReminders((prev) => prev.filter((r) => r.minutesBefore !== minutesBefore)); + }; + const availablePresets = REMINDER_PRESETS.filter( + (p) => !reminders.some((r) => r.minutesBefore === p), + ); return ( -
} label="Reminder"> - setAlertOpen((v) => !v)} - > - {alertLabel} - - {alertOpen && ( +
} label="Reminders"> + {reminders.length > 0 && ( + + {reminders.map((r) => ( + + {formatReminder(r.minutesBefore)} + removeReminder(r.minutesBefore)} + hitSlop={8} + style={styles.reminderRemove} + > + + + + ))} + + )} + {availablePresets.length > 0 && ( + setReminderPickerOpen((v) => !v)} + > + + + {reminders.length === 0 ? 'Add reminder' : 'Add another reminder'} + + + )} + {reminderPickerOpen && ( - {ALERT_OPTIONS.map((opt) => ( + {availablePresets.map((preset) => ( { - setAlert(opt.value); - setAlertOpen(false); - }} + key={preset} + style={styles.popoverRow} + onPress={() => addReminder(preset)} > - {opt.label} + {formatReminder(preset)} ))} )}
+
} label="Location"> + +
+ +
} label="Video call"> + +
+
} + icon={} label="Description" > void; + calendars: Calendar[]; + /** Imports the selected events into the chosen calendar; returns count. */ + onImport: (events: Partial[], calendarId: string) => Promise; + onImported?: (count: number) => void; +} + +function eventDateLabel(event: Partial): string | null { + if (!event.start) return null; + const d = parseISO(event.start); + if (isNaN(d.getTime())) return null; + return event.showWithoutTime ? format(d, 'EEE, MMM d, yyyy') : format(d, 'EEE, MMM d · HH:mm'); +} + +export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + + const writable = React.useMemo( + () => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false), + [calendars], + ); + + const [parsed, setParsed] = React.useState[]>([]); + const [selected, setSelected] = React.useState>(new Set()); + const [calendarId, setCalendarId] = React.useState(''); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [importing, setImporting] = React.useState(false); + const [result, setResult] = React.useState(null); + + const slideY = React.useRef(new Animated.Value(900)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + + const resetState = React.useCallback(() => { + setParsed([]); + setSelected(new Set()); + setError(null); + setLoading(false); + setImporting(false); + setResult(null); + }, []); + + React.useEffect(() => { + if (visible) { + resetState(); + if (writable[0]) setCalendarId(writable[0].id); + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }), + ]).start(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, slideY, overlayOpacity, resetState]); + + const pickFile = async () => { + const picker = loadDocumentPicker(); + if (!picker) { + Alert.alert( + 'Import unavailable', + 'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.', + ); + return; + } + setError(null); + let res; + try { + res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not open the file picker'); + return; + } + if (res.canceled || res.assets.length === 0) return; + const asset = res.assets[0]; + if (asset.size && asset.size > 5 * 1024 * 1024) { + setError('That file is too large (max 5 MB).'); + return; + } + + setLoading(true); + try { + // Upload the .ics so the server can parse it (CalendarEvent/parse). + const { blobId } = await uploadBlob(asset.uri, 'text/calendar'); + const events = await parseCalendarBlob(blobId); + const usable = events.filter((e) => !!e.start || e['@type'] === 'Task'); + if (usable.length === 0) { + setError('No events were found in that file.'); + return; + } + setParsed(usable); + setSelected(new Set(usable.map((_, i) => i))); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not read or parse that file.'); + } finally { + setLoading(false); + } + }; + + const toggle = (idx: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; + const selectAll = () => setSelected(new Set(parsed.map((_, i) => i))); + const selectNone = () => setSelected(new Set()); + + const doImport = async () => { + if (!calendarId) { + Alert.alert('No calendar', 'Create a calendar before importing events.'); + return; + } + const toImport = parsed.filter((_, i) => selected.has(i)); + if (toImport.length === 0) return; + setImporting(true); + try { + const count = await onImport(toImport, calendarId); + setResult(count); + onImported?.(count); + } catch (e) { + setError(e instanceof Error ? e.message : 'Import failed.'); + } finally { + setImporting(false); + } + }; + + return ( + + + + + + + + Import calendar + + + + + + {result !== null ? ( + + + + + + Imported {result} event{result === 1 ? '' : 's'}. + + + + ) : parsed.length === 0 ? ( + + { void pickFile(); }} disabled={loading}> + {loading ? ( + + ) : ( + + )} + {loading ? 'Parsing…' : 'Choose an .ics file'} + iCalendar — single or multiple events + + {!!error && ( + + + {error} + + )} + + ) : ( + <> + {writable.length > 1 && ( + + {writable.map((cal) => ( + setCalendarId(cal.id)} + style={[styles.calChip, cal.id === calendarId && styles.calChipActive]} + > + + {cal.name} + + ))} + + )} + + + + {parsed.length} found · {selected.size} selected + + + All + None + + + + + {parsed.map((event, idx) => { + const isSelected = selected.has(idx); + const date = eventDateLabel(event); + return ( + toggle(idx)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {isSelected && } + + + + {event.title || 'Untitled event'} + {!!date && {date}} + + + ); + })} + + + {!!error && ( + + + {error} + + )} + + + + + + + )} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: '85%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + title: { ...typography.h3, color: c.text }, + close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl }, + dropZone: { + width: '100%', + borderWidth: 2, + borderColor: c.border, + borderStyle: 'dashed', + borderRadius: radius.lg, + paddingVertical: spacing.xxxl, + alignItems: 'center', + gap: spacing.sm, + }, + dropTitle: { ...typography.bodyMedium, color: c.text }, + dropHint: { ...typography.caption, color: c.textMuted }, + successBadge: { + width: 56, height: 56, borderRadius: radius.full, + backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center', + }, + successText: { ...typography.bodyMedium, color: c.text }, + calChips: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.sm }, + calChip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + maxWidth: 160, + }, + calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg }, + calSwatch: { width: 10, height: 10, borderRadius: 5 }, + calChipText: { ...typography.caption, color: c.text, flexShrink: 1 }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + toolbarCount: { ...typography.caption, color: c.textMuted }, + link: { ...typography.captionMedium, color: c.primary }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + minHeight: 48, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + checkbox: { + width: 20, height: 20, borderRadius: radius.xs, + borderWidth: 1, borderColor: c.border, + alignItems: 'center', justifyContent: 'center', + }, + checkboxOn: { backgroundColor: c.primary, borderColor: c.primary }, + rowName: { ...typography.body, color: c.text }, + rowDate: { ...typography.caption, color: c.textMuted, marginTop: 1 }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginHorizontal: spacing.lg, + marginTop: spacing.sm, + padding: spacing.md, + borderRadius: radius.sm, + backgroundColor: c.errorBg, + borderWidth: 1, + borderColor: c.errorBorder, + }, + errorText: { ...typography.caption, color: c.errorForeground, flex: 1 }, + footer: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: c.border, + }, + }); +} diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx new file mode 100644 index 00000000..495eeee5 --- /dev/null +++ b/src/components/calendar/ICalSubscriptionSheet.tsx @@ -0,0 +1,262 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native'; +import { formatDistanceToNow } from 'date-fns'; +import Button from '../Button'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils'; +import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store'; + +interface Props { + visible: boolean; + onClose: () => void; +} + +export function ICalSubscriptionSheet({ visible, onClose }: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions); + const syncing = useCalendarSubscriptionsStore((s) => s.syncing); + const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription); + const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription); + const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription); + + const [name, setName] = React.useState(''); + const [url, setUrl] = React.useState(''); + const [color, setColor] = React.useState(CALENDAR_COLOR_PALETTE[0]); + const [adding, setAdding] = React.useState(false); + const [formError, setFormError] = React.useState(null); + + const slideY = React.useRef(new Animated.Value(900)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + if (visible) { + setName(''); + setUrl(''); + setColor(CALENDAR_COLOR_PALETTE[0]); + setFormError(null); + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + const handleAdd = async () => { + const n = name.trim(); + const u = url.trim(); + if (!n || !u) { + setFormError('Enter a name and a feed URL.'); + return; + } + if (!/^(https?|webcal):\/\//i.test(u)) { + setFormError('URL must start with https://, http:// or webcal://'); + return; + } + setFormError(null); + setAdding(true); + try { + const sub = await addSubscription({ name: n, url: u, color }); + if (sub.lastError) { + setFormError(sub.lastError); + } else { + setName(''); + setUrl(''); + } + } catch (e) { + setFormError(e instanceof Error ? e.message : 'Could not add subscription.'); + } finally { + setAdding(false); + } + }; + + return ( + + + + + + + + + Calendar subscriptions + + + + + + + Add a feed + + + + {CALENDAR_COLOR_PALETTE.map((col) => ( + setColor(col)} + style={[ + styles.colorDot, + { backgroundColor: col }, + color === col && styles.colorDotActive, + ]} + /> + ))} + + {!!formError && ( + + + {formError} + + )} + + + {subscriptions.length > 0 && ( + <> + Your subscriptions + {subscriptions.map((sub) => { + const busy = !!syncing[sub.id]; + return ( + + + + {sub.name} + + {sub.lastError + ? sub.lastError + : sub.lastSyncAt + ? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}` + : 'Not synced yet'} + + + { void syncSubscription(sub.id); }} + hitSlop={6} + style={styles.subBtn} + disabled={busy} + > + {busy ? ( + + ) : ( + + )} + + { void removeSubscription(sub.id); }} + hitSlop={6} + style={styles.subBtn} + > + + + + ); + })} + + )} + + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '88%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + title: { flex: 1, ...typography.h3, color: c.text }, + close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm }, + scroll: { padding: spacing.lg, gap: spacing.sm }, + sectionLabel: { ...typography.bodySemibold, color: c.text, marginBottom: spacing.xs }, + input: { + ...typography.body, + color: c.text, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: c.surface, + marginBottom: spacing.sm, + }, + colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md }, + colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' }, + colorDotActive: { borderColor: c.text }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + padding: spacing.md, + borderRadius: radius.sm, + backgroundColor: c.errorBg, + borderWidth: 1, + borderColor: c.errorBorder, + marginBottom: spacing.sm, + }, + errorText: { ...typography.caption, color: c.errorForeground, flex: 1 }, + subRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + subSwatch: { width: 14, height: 14, borderRadius: 7 }, + subName: { ...typography.body, color: c.text }, + subMeta: { ...typography.caption, color: c.textMuted, marginTop: 1 }, + subBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm }, + }); +} diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx new file mode 100644 index 00000000..58b040a5 --- /dev/null +++ b/src/components/calendar/TasksSheet.tsx @@ -0,0 +1,371 @@ +import React from 'react'; +import { + Animated, + Dimensions, + Easing, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import { + Calendar as CalendarIcon, + Check, + ListChecks, + Plus, + Trash2, + X, +} from 'lucide-react-native'; +import { format, parseISO } from 'date-fns'; +import type { Calendar, CalendarEvent } from '../../api/types'; +import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { getCalendarColor } from '../../lib/calendar-utils'; +import { useSheetDrag } from '../../lib/use-sheet-drag'; + +interface TasksSheetProps { + visible: boolean; + tasks: CalendarEvent[]; + calendars: Calendar[]; + onClose: () => void; + onCreate: (task: Partial, calendarId: string) => Promise | void; + onToggle: (id: string) => Promise | void; + onDelete: (id: string) => Promise | void; +} + +function isCompleted(task: CalendarEvent): boolean { + return task.progress === 'completed'; +} + +function dueLabel(task: CalendarEvent): string | null { + if (!task.due) return null; + const d = parseISO(task.due); + if (isNaN(d.getTime())) return null; + return format(d, 'EEE, MMM d'); +} + +// Tasks first by completion (open first), then by due date (soonest first, +// undated last), then title. +function compareTasks(a: CalendarEvent, b: CalendarEvent): number { + const ac = isCompleted(a); + const bc = isCompleted(b); + if (ac !== bc) return ac ? 1 : -1; + const ad = a.due ? parseISO(a.due).getTime() : Infinity; + const bd = b.due ? parseISO(b.due).getTime() : Infinity; + if (ad !== bd) return ad - bd; + return (a.title || '').localeCompare(b.title || ''); +} + +export function TasksSheet({ + visible, + tasks, + calendars, + onClose, + onCreate, + onToggle, + onDelete, +}: TasksSheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ + slideY, + closedY: Dimensions.get('window').height, + onClose, + }); + + const writableCalendars = React.useMemo( + () => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false), + [calendars], + ); + + const [title, setTitle] = React.useState(''); + const [calendarId, setCalendarId] = React.useState(''); + const [due, setDue] = React.useState(null); + const [showDuePicker, setShowDuePicker] = React.useState(false); + const [saving, setSaving] = React.useState(false); + + React.useEffect(() => { + if (visible && !calendarId && writableCalendars[0]) { + setCalendarId(writableCalendars[0].id); + } + }, [visible, calendarId, writableCalendars]); + + React.useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: Dimensions.get('window').height, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + const sorted = React.useMemo(() => [...tasks].sort(compareTasks), [tasks]); + + const handleAdd = async () => { + const t = title.trim(); + if (!t || !calendarId || saving) return; + setSaving(true); + try { + const task: Partial = { + title: t, + progress: 'needs-action', + }; + if (due) { + task.due = format(due, "yyyy-MM-dd'T'HH:mm:ss"); + } + await onCreate(task, calendarId); + setTitle(''); + setDue(null); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + + + + + + + + + Tasks + + + + + + + + { void handleAdd(); }} + /> + setShowDuePicker(true)} + style={[styles.dueChip, due && styles.dueChipActive]} + hitSlop={6} + > + + {due && {format(due, 'MMM d')}} + + { void handleAdd(); }} + disabled={!title.trim() || !calendarId || saving} + style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]} + > + + + + + {writableCalendars.length > 1 && ( + + {writableCalendars.map((cal) => ( + setCalendarId(cal.id)} + style={[styles.calChip, cal.id === calendarId && styles.calChipActive]} + > + + {cal.name} + + ))} + + )} + + + {sorted.length === 0 ? ( + + + No tasks yet + + ) : ( + sorted.map((task) => { + const completed = isCompleted(task); + const due = dueLabel(task); + return ( + + { void onToggle(task.id); }} + style={[styles.checkbox, completed && styles.checkboxChecked]} + hitSlop={8} + > + {completed && } + + + + {task.title || 'Untitled task'} + + {due && {due}} + + { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}> + + + + ); + }) + )} + + + + + {showDuePicker && ( + { + setShowDuePicker(false); + if (d) setDue(d); + }} + /> + )} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + overlayPress: { flex: 1 }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '85%', + backgroundColor: c.background, + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + borderTopWidth: 1, + borderTopColor: c.border, + }, + sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 }, + handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm }, + handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.surfaceActive }, + + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, + }, + headerTitle: { flex: 1, ...typography.h3, color: c.text }, + closeBtn: { padding: 4 }, + + addRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + }, + addInput: { + flex: 1, + ...typography.body, + color: c.text, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: c.surface, + }, + dueChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + height: 38, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + dueChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg }, + dueChipText: { ...typography.caption, color: c.primary }, + addBtn: { + width: 38, + height: 38, + borderRadius: radius.sm, + backgroundColor: c.primary, + alignItems: 'center', + justifyContent: 'center', + }, + addBtnDisabled: { opacity: 0.5 }, + + calChips: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm }, + calChip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + maxWidth: 160, + }, + calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg }, + calSwatch: { width: 10, height: 10, borderRadius: 5 }, + calChipText: { ...typography.caption, color: c.text, flexShrink: 1 }, + + list: { flexGrow: 0 }, + listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg }, + empty: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.sm }, + emptyText: { ...typography.body, color: c.textMuted }, + + taskRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + checkbox: { + width: 22, + height: 22, + borderRadius: radius.xs, + borderWidth: 2, + borderColor: c.border, + alignItems: 'center', + justifyContent: 'center', + }, + checkboxChecked: { backgroundColor: c.primary, borderColor: c.primary }, + taskText: { flex: 1 }, + taskTitle: { ...typography.body, color: c.text }, + taskTitleDone: { textDecorationLine: 'line-through', color: c.textMuted }, + taskDue: { ...typography.caption, color: c.textMuted, marginTop: 2 }, + taskDelete: { padding: 4 }, + }); +} diff --git a/src/components/contacts/AddressBookPickerSheet.tsx b/src/components/contacts/AddressBookPickerSheet.tsx new file mode 100644 index 00000000..f3e0eca5 --- /dev/null +++ b/src/components/contacts/AddressBookPickerSheet.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { BookUser, X, Check, Plus } from 'lucide-react-native'; +import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useSheetDrag } from '../../lib/use-sheet-drag'; +import { useContactsStore } from '../../stores/contacts-store'; + +interface Props { + visible: boolean; + onClose: () => void; + /** Book the contact(s) currently live in - shown with a check, not selectable. */ + currentBookId?: string | null; + onPick: (addressBookId: string) => void; + title?: string; +} + +export default function AddressBookPickerSheet({ + visible, onClose, currentBookId, onPick, title = 'Move to address book', +}: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const addressBooks = useContactsStore((s) => s.addressBooks); + const createAddressBook = useContactsStore((s) => s.createAddressBook); + + const [creating, setCreating] = React.useState(false); + const [newName, setNewName] = React.useState(''); + const [busy, setBusy] = React.useState(false); + + const slideY = React.useRef(new Animated.Value(600)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose }); + + React.useEffect(() => { + if (visible) { + setCreating(false); + setNewName(''); + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + const handleCreate = async () => { + const name = newName.trim(); + if (!name || busy) return; + setBusy(true); + try { + const book = await createAddressBook(name); + setCreating(false); + setNewName(''); + onPick(book.id); + } catch (err) { + Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error'); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + + + + + + {title} + + + + + + + + {addressBooks.map((book) => { + const isCurrent = book.id === currentBookId; + const canWrite = book.myRights?.mayWrite !== false; + const canTarget = canWrite && !isCurrent; + return ( + onPick(book.id) : undefined} + disabled={!canTarget} + style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]} + > + + + {book.name} + + {isCurrent && } + + ); + })} + + {creating ? ( + + { void handleCreate(); }} + /> + { void handleCreate(); }} + disabled={!newName.trim() || busy} + style={[styles.createBtn, (!newName.trim() || busy) && styles.createBtnDisabled]} + hitSlop={6} + > + + + + ) : ( + setCreating(true)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + New address book… + + )} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '75%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + }, + handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm }, + handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + title: { ...typography.bodySemibold, color: c.text }, + close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + minHeight: 44, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + rowLabel: { ...typography.body, color: c.text, flex: 1 }, + rowLabelDisabled: { color: c.textMuted }, + createRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + createInput: { + flex: 1, + ...typography.body, + color: c.text, + height: componentSizes.inputHeight, + paddingHorizontal: spacing.md, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.background, + }, + createBtn: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: c.primary, + borderRadius: radius.full, + }, + createBtnDisabled: { opacity: 0.4 }, + }); +} diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx new file mode 100644 index 00000000..8fadf53c --- /dev/null +++ b/src/components/contacts/ContactImportSheet.tsx @@ -0,0 +1,391 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import * as FileSystem from 'expo-file-system/legacy'; +import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native'; +import type { ContactCard } from '../../api/types'; +import { parseVCard, detectDuplicates } from '../../lib/vcard'; +import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils'; +import { useContactsStore } from '../../stores/contacts-store'; +import Button from '../Button'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; + +// expo-document-picker is loaded lazily on first use; its native module is not +// linked into every build (matches the FilesScreen upload flow). +type DocumentPickerModule = typeof import('expo-document-picker'); +let documentPickerModule: DocumentPickerModule | null = null; +function loadDocumentPicker(): DocumentPickerModule | null { + if (documentPickerModule) return documentPickerModule; + try { + documentPickerModule = require('expo-document-picker') as DocumentPickerModule; + return documentPickerModule; + } catch { + return null; + } +} + +interface Props { + visible: boolean; + onClose: () => void; + /** Address book the imported contacts are created in. */ + targetBookId: string | null; + targetBookName?: string; + onChangeTarget?: () => void; + onImported?: (count: number) => void; +} + +export default function ContactImportSheet({ + visible, onClose, targetBookId, targetBookName, onChangeTarget, onImported, +}: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const existing = useContactsStore((s) => s.contacts); + const importContacts = useContactsStore((s) => s.importContacts); + + const [parsed, setParsed] = React.useState([]); + const [selected, setSelected] = React.useState>(new Set()); + const [duplicates, setDuplicates] = React.useState>(new Map()); + const [error, setError] = React.useState(null); + const [importing, setImporting] = React.useState(false); + const [result, setResult] = React.useState(null); + + const slideY = React.useRef(new Animated.Value(900)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + + const resetState = React.useCallback(() => { + setParsed([]); + setSelected(new Set()); + setDuplicates(new Map()); + setError(null); + setImporting(false); + setResult(null); + }, []); + + React.useEffect(() => { + if (visible) { + resetState(); + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity, resetState]); + + const pickFile = async () => { + const picker = loadDocumentPicker(); + if (!picker) { + Alert.alert( + 'Import unavailable', + 'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.', + ); + return; + } + setError(null); + let res; + try { + res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not open the file picker'); + return; + } + if (res.canceled || res.assets.length === 0) return; + const asset = res.assets[0]; + + if (asset.size && asset.size > 5 * 1024 * 1024) { + setError('That file is too large (max 5 MB).'); + return; + } + + try { + const text = await FileSystem.readAsStringAsync(asset.uri); + const contacts = parseVCard(text); + if (contacts.length === 0) { + setError('No contacts were found in that file.'); + return; + } + const dupes = detectDuplicates(existing, contacts); + const initial = new Set(); + contacts.forEach((_, idx) => { if (!dupes.has(idx)) initial.add(idx); }); + setParsed(contacts); + setDuplicates(dupes); + setSelected(initial); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not read or parse that file.'); + } + }; + + const toggle = (idx: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; + + const selectAll = () => setSelected(new Set(parsed.map((_, i) => i))); + const selectNone = () => setSelected(new Set()); + + const doImport = async () => { + if (!targetBookId) { + Alert.alert('No address book', 'Create an address book before importing contacts.'); + return; + } + const toImport = parsed.filter((_, i) => selected.has(i)); + if (toImport.length === 0) return; + setImporting(true); + try { + const count = await importContacts(toImport, targetBookId); + setResult(count); + onImported?.(count); + } catch (e) { + setError(e instanceof Error ? e.message : 'Import failed.'); + } finally { + setImporting(false); + } + }; + + return ( + + + + + + + + Import contacts + + + + + + {result !== null ? ( + + + + + + Imported {result} contact{result === 1 ? '' : 's'}. + + + + ) : parsed.length === 0 ? ( + + { void pickFile(); }}> + + Choose a vCard file + .vcf / .vcard — single or multiple contacts + + {!!error && ( + + + {error} + + )} + + ) : ( + <> + + + + Import into {targetBookName || 'address book'} + + {onChangeTarget && Change} + + + + + {parsed.length} found · {selected.size} selected + + + + All + + + None + + + + + + {parsed.map((contact, idx) => { + const name = getContactDisplayName(contact); + const email = getContactPrimaryEmail(contact); + const isDupe = duplicates.has(idx); + const isSelected = selected.has(idx); + return ( + toggle(idx)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {isSelected && } + + + + {name || email || '—'} + {!!email && !!name && ( + {email} + )} + + {isDupe && ( + Duplicate + )} + + ); + })} + + + {!!error && ( + + + {error} + + )} + + + + + + + )} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: '85%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + title: { ...typography.h3, color: c.text }, + close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl }, + dropZone: { + width: '100%', + borderWidth: 2, + borderColor: c.border, + borderStyle: 'dashed', + borderRadius: radius.lg, + paddingVertical: spacing.xxxl, + alignItems: 'center', + gap: spacing.sm, + }, + dropTitle: { ...typography.bodyMedium, color: c.text }, + dropHint: { ...typography.caption, color: c.textMuted }, + successBadge: { + width: 56, height: 56, borderRadius: radius.full, + backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center', + }, + successText: { ...typography.bodyMedium, color: c.text }, + targetRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + targetText: { ...typography.caption, color: c.textSecondary, flex: 1 }, + targetChange: { ...typography.captionMedium, color: c.primary }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + toolbarCount: { ...typography.caption, color: c.textMuted }, + link: { ...typography.captionMedium, color: c.primary }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + minHeight: 48, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + checkbox: { + width: 20, height: 20, borderRadius: radius.xs, + borderWidth: 1, borderColor: c.border, + alignItems: 'center', justifyContent: 'center', + }, + checkboxOn: { backgroundColor: c.primary, borderColor: c.primary }, + rowName: { ...typography.body, color: c.text }, + rowEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 }, + dupeBadge: { + ...typography.small, + color: c.warning, + backgroundColor: c.warningBg, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: radius.xs, + overflow: 'hidden', + }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginHorizontal: spacing.lg, + marginTop: spacing.sm, + padding: spacing.md, + borderRadius: radius.sm, + backgroundColor: c.errorBg, + borderWidth: 1, + borderColor: c.errorBorder, + }, + errorText: { ...typography.caption, color: c.errorForeground, flex: 1 }, + footer: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: c.border, + }, + }); +} diff --git a/src/components/contacts/TagAssignSheet.tsx b/src/components/contacts/TagAssignSheet.tsx new file mode 100644 index 00000000..2aadc17b --- /dev/null +++ b/src/components/contacts/TagAssignSheet.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Tag, X, Plus } from 'lucide-react-native'; +import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useSheetDrag } from '../../lib/use-sheet-drag'; +import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store'; + +interface Props { + visible: boolean; + onClose: () => void; + /** Called with the chosen tag (existing or freshly typed). */ + onPick: (keyword: string) => void; + title?: string; +} + +export default function TagAssignSheet({ + visible, onClose, onPick, title = 'Add tag', +}: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const keywords = useContactsStore(selectKeywordsUsed); + + const [input, setInput] = React.useState(''); + + const slideY = React.useRef(new Animated.Value(600)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose }); + + React.useEffect(() => { + if (visible) { + setInput(''); + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + const submitNew = () => { + const kw = input.trim(); + if (!kw) return; + onPick(kw); + }; + + return ( + + + + + + + + + + + {title} + + + + + + + + + + + + + + + {keywords.length === 0 ? ( + No tags yet. Type one above. + ) : ( + keywords.map((kw) => ( + onPick(kw.keyword)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {kw.keyword} + {kw.count} + + )) + )} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '75%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + }, + handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm }, + handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + title: { ...typography.bodySemibold, color: c.text }, + close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + input: { + flex: 1, + ...typography.body, + color: c.text, + height: componentSizes.inputHeight, + paddingHorizontal: spacing.md, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.background, + }, + addBtn: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: c.primary, + borderRadius: radius.full, + }, + addBtnDisabled: { opacity: 0.4 }, + empty: { ...typography.caption, color: c.textMuted, textAlign: 'center', paddingVertical: spacing.xl }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + minHeight: 44, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + rowLabel: { ...typography.body, color: c.text, flex: 1 }, + rowCount: { ...typography.caption, color: c.textMuted }, + }); +} diff --git a/src/components/contacts/index.ts b/src/components/contacts/index.ts index 1caaea55..a61c4b4c 100644 --- a/src/components/contacts/index.ts +++ b/src/components/contacts/index.ts @@ -1,4 +1,7 @@ +export { default as AddressBookPickerSheet } from './AddressBookPickerSheet'; +export { default as ContactImportSheet } from './ContactImportSheet'; export { default as ContactListRow } from './ContactListRow'; export { default as ContactPickerSheet } from './ContactPickerSheet'; export { default as ContactsSidebarDrawer } from './ContactsSidebarDrawer'; +export { default as TagAssignSheet } from './TagAssignSheet'; export { default as FieldBlock, FieldRow } from './FieldBlock'; diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx new file mode 100644 index 00000000..d6eb7024 --- /dev/null +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -0,0 +1,305 @@ +import React from 'react'; +import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native'; +import { + CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle, +} from 'lucide-react-native'; +import { format, parseISO } from 'date-fns'; +import type { Email, CalendarEvent } from '../../api/types'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { parseCalendarBlob } from '../../api/calendar'; +import { useCalendarStore } from '../../stores/calendar-store'; +import { useAccountStore } from '../../stores/account-store'; +import { useSettingsStore } from '../../stores/settings-store'; +import { + findCalendarAttachment, + findParticipantByEmail, + getOrganizerName, + buildReplyTo, + inferInvitationMethod, + type InvitationMethod, +} from '../../lib/calendar-invitation'; + +type BannerState = 'loading' | 'parsed' | 'done' | 'error'; +type RsvpStatus = 'accepted' | 'tentative' | 'declined'; + +interface Props { + email: Email; +} + +export function CalendarInvitationBanner({ email }: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); + const calendars = useCalendarStore((s) => s.calendars); + const importEvents = useCalendarStore((s) => s.importEvents); + const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); + const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null); + + const attachment = React.useMemo(() => findCalendarAttachment(email), [email]); + + const [state, setState] = React.useState('loading'); + const [event, setEvent] = React.useState | null>(null); + const [method, setMethod] = React.useState('unknown'); + const [rsvpStatus, setRsvpStatus] = React.useState(null); + const [notice, setNotice] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + if (!attachment || !enabled) return; + setState('loading'); + (async () => { + try { + const events = await parseCalendarBlob(attachment.blobId); + if (cancelled) return; + if (events.length === 0) { + setState('error'); + return; + } + const parsed = events[0]; + setEvent(parsed); + setMethod(inferInvitationMethod(parsed)); + setState('parsed'); + } catch { + if (!cancelled) setState('error'); + } + })(); + return () => { cancelled = true; }; + }, [attachment, enabled]); + + if (!attachment || !enabled || state === 'error') return null; + + if (state === 'loading') { + return ( + + + Reading invitation… + + ); + } + if (!event) return null; + + const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start); + const startDate = startStr ? parseISO(startStr) : null; + const dateLabel = startDate && !isNaN(startDate.getTime()) + ? (event.showWithoutTime + ? format(startDate, 'EEEE, MMM d, yyyy') + : format(startDate, 'EEE, MMM d · HH:mm')) + : null; + const organizer = getOrganizerName(event); + const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; + const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; + const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null; + const canRsvp = method !== 'cancel' && method !== 'reply' && !!me; + + const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false); + + const ensureImportedAndRsvp = async (status: RsvpStatus) => { + if (busy || !writableCalendar) return; + setBusy(true); + setNotice(null); + try { + // Make sure the event exists in a local calendar (dedupes by UID). + await importEvents([event], writableCalendar.id); + // Re-read from the store to get the server-assigned id + participant. + const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid); + const participant = stored && activeEmail + ? findParticipantByEmail(stored, [activeEmail]) + : me; + if (stored && participant) { + await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event)); + setRsvpStatus(status); + setNotice('Response sent'); + } else { + setNotice('Added to calendar'); + } + setState('done'); + } catch { + setNotice('Could not send your response'); + } finally { + setBusy(false); + } + }; + + const handleImport = async () => { + if (busy || !writableCalendar) return; + setBusy(true); + setNotice(null); + try { + const count = await importEvents([event], writableCalendar.id); + setNotice(count > 0 ? 'Added to calendar' : 'Already in your calendar'); + setState('done'); + } catch { + setNotice('Could not add the event'); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + + {event.title || 'Calendar invitation'} + {method === 'cancel' && This event was cancelled} + + + + {dateLabel && ( + } text={dateLabel} styles={styles} /> + )} + {location ? ( + } text={location} styles={styles} /> + ) : null} + {videoUri ? ( + { void Linking.openURL(videoUri); }}> + + ) : null} + {organizer ? ( + } text={`Organizer: ${organizer}`} styles={styles} /> + ) : null} + + {notice && {notice}} + + {state !== 'done' && ( + + {canRsvp ? ( + <> + } + disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} /> + } + disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} /> + } + disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} /> + + ) : ( + { void handleImport(); }} + disabled={busy} + > + {busy ? ( + + ) : ( + + )} + Add to calendar + + )} + + )} + + {!writableCalendar && state !== 'done' && ( + + + No writable calendar available + + )} + + ); +} + +function Row({ + icon, text, styles, +}: { + icon: React.ReactNode; + text: string; + styles: ReturnType; +}) { + return ( + + {icon} + {text} + + ); +} + +function RsvpBtn({ + label, icon, active, activeColor, disabled, onPress, c, styles, +}: { + label: string; + icon: React.ReactNode; + active: boolean; + activeColor: string; + disabled?: boolean; + onPress: () => void; + c: ThemePalette; + styles: ReturnType; +}) { + return ( + + {icon} + {label} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + banner: { + marginHorizontal: spacing.lg, + marginBottom: spacing.md, + padding: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + gap: spacing.xs, + }, + loadingText: { ...typography.caption, color: c.textMuted }, + headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.xs }, + iconBadge: { + width: 32, height: 32, borderRadius: radius.sm, + backgroundColor: c.primaryBg, alignItems: 'center', justifyContent: 'center', + }, + title: { ...typography.bodySemibold, color: c.text }, + cancelled: { ...typography.caption, color: c.error, marginTop: 2 }, + detailRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 2 }, + detailText: { flex: 1, ...typography.caption, color: c.textSecondary }, + notice: { ...typography.captionMedium, color: c.primary, marginTop: spacing.xs }, + actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, + rsvpBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + }, + rsvpBtnText: { ...typography.caption, color: c.text }, + importBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + backgroundColor: c.primary, + }, + importBtnText: { ...typography.bodyMedium, color: c.primaryForeground }, + warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs }, + warnText: { ...typography.caption, color: c.warning }, + }); +} diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index c4f8e87c..bf422acb 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -16,6 +16,8 @@ export function ComposingSettings() { const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords); const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords); + const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds); + const updateSetting = useSettingsStore((s) => s.updateSetting); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); @@ -37,6 +39,14 @@ export function ComposingSettings() { setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw)); }; + const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [ + { label: 'Off', value: 0 }, + { label: '5s', value: 5 }, + { label: '10s', value: 10 }, + { label: '20s', value: 20 }, + { label: '30s', value: 30 }, + ]; + return ( + + Undo send delay + + Hold outgoing mail for a few seconds so you can cancel it. Requires server support. + + + {SEND_DELAY_OPTIONS.map((opt) => { + const active = sendDelaySeconds === opt.value; + return ( + updateSetting('sendDelaySeconds', opt.value)} + style={[styles.segment, active && styles.segmentActive]} + > + + {opt.label} + + + ); + })} + + + makeStyles(c), [c]); const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter); const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter); const contacts = useContactsStore((s) => s.contacts); + const books = useContactsStore(selectAddressBooksWithCount); + const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks); + const fetchContacts = useContactsStore((s) => s.fetchContacts); + const createAddressBook = useContactsStore((s) => s.createAddressBook); + const renameAddressBook = useContactsStore((s) => s.renameAddressBook); + const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook); const [exporting, setExporting] = React.useState(false); + const [importOpen, setImportOpen] = React.useState(false); - const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]); + const [editingId, setEditingId] = React.useState(null); + const [editName, setEditName] = React.useState(''); + const [adding, setAdding] = React.useState(false); + const [newName, setNewName] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null); + + const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]); const exportLabel = exportable.length === 0 ? 'No contacts to export' : `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`; + const importTargetBookId = books[0]?.id ?? null; const handleExport = async () => { if (exportable.length === 0 || exporting) return; @@ -50,40 +70,256 @@ export function ContactsSettings() { } }; + const startRename = (id: string, name: string) => { + setAdding(false); + setEditingId(id); + setEditName(name); + }; + + const commitRename = async () => { + const name = editName.trim(); + if (!editingId || !name || busy) { setEditingId(null); return; } + setBusy(true); + try { + await renameAddressBook(editingId, name); + setEditingId(null); + } catch (err) { + Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error'); + } finally { + setBusy(false); + } + }; + + const commitCreate = async () => { + const name = newName.trim(); + if (!name || busy) return; + setBusy(true); + try { + await createAddressBook(name); + setAdding(false); + setNewName(''); + } catch (err) { + Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error'); + } finally { + setBusy(false); + } + }; + + const confirmDelete = async () => { + if (!deleteTarget) return; + const { id } = deleteTarget; + setDeleteTarget(null); + try { + await deleteAddressBook(id); + } catch (err) { + Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error'); + } + }; + return ( - - + - - - - - - + + - + + + + + + + + + - Web only - - + {books.map((book) => ( + + + {editingId === book.id ? ( + <> + { void commitRename(); }} + placeholderTextColor={c.textMuted} + /> + { void commitRename(); }} hitSlop={6} style={styles.iconBtn}> + + + setEditingId(null)} hitSlop={6} style={styles.iconBtn}> + + + + ) : ( + <> + + {book.name} + + {book.count} contact{book.count === 1 ? '' : 's'} + + + {book.myRights?.mayWrite !== false && ( + startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}> + + + )} + {book.myRights?.mayDelete !== false && books.length > 1 && ( + setDeleteTarget({ id: book.id, name: book.name, count: book.count })} + hitSlop={6} + style={styles.iconBtn} + > + + + )} + + )} + + ))} + + {adding ? ( + + + { void commitCreate(); }} + /> + { void commitCreate(); }} + disabled={!newName.trim() || busy} + hitSlop={6} + style={styles.iconBtn} + > + + + { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}> + + + + ) : ( + { setEditingId(null); setAdding(true); }} + > + + New address book + + )} + + + + + + + setImportOpen(false)} + targetBookId={importTargetBookId} + targetBookName={books[0]?.name} + onImported={() => { void fetchContacts(); }} + /> + + 0 ? ` Its ${deleteTarget.count} contact${deleteTarget.count === 1 ? '' : 's'} will be removed from this book.` : ''} This cannot be undone.` + : '' + } + variant="destructive" + confirmText="Delete" + onConfirm={() => { void confirmDelete(); }} + onCancel={() => setDeleteTarget(null)} + /> + ); } + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + bookRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + bookName: { ...typography.body, color: c.text }, + bookCount: { ...typography.caption, color: c.textMuted, marginTop: 1 }, + bookInput: { + flex: 1, + ...typography.body, + color: c.text, + height: componentSizes.inputHeight, + paddingHorizontal: spacing.md, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.background, + }, + iconBtn: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.sm, + }, + addRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + }, + addLabel: { ...typography.body, color: c.primary }, + }); +} diff --git a/src/lib/__tests__/calendar-alerts.test.ts b/src/lib/__tests__/calendar-alerts.test.ts new file mode 100644 index 00000000..034609df --- /dev/null +++ b/src/lib/__tests__/calendar-alerts.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + offsetToMinutesBefore, + minutesBeforeToOffset, + alertsToReminders, + remindersToAlerts, + formatReminder, +} from '../calendar-alerts'; + +describe('calendar-alerts', () => { + describe('offsetToMinutesBefore', () => { + it('parses minute offsets', () => { + expect(offsetToMinutesBefore('-PT15M')).toBe(15); + expect(offsetToMinutesBefore('-PT30M')).toBe(30); + }); + it('parses hour and day offsets', () => { + expect(offsetToMinutesBefore('-PT1H')).toBe(60); + expect(offsetToMinutesBefore('-P1D')).toBe(60 * 24); + }); + it('treats PT0S as at-time (0)', () => { + expect(offsetToMinutesBefore('PT0S')).toBe(0); + }); + it('returns negative minutesBefore for post-start offsets', () => { + expect(offsetToMinutesBefore('PT10M')).toBe(-10); + }); + it('returns null for garbage', () => { + expect(offsetToMinutesBefore('nonsense')).toBeNull(); + }); + }); + + describe('minutesBeforeToOffset', () => { + it('round-trips through offsetToMinutesBefore', () => { + for (const m of [0, 5, 15, 30, 60, 120, 60 * 24, 60 * 24 * 7]) { + expect(offsetToMinutesBefore(minutesBeforeToOffset(m))).toBe(m); + } + }); + it('emits PT0S at time of event', () => { + expect(minutesBeforeToOffset(0)).toBe('PT0S'); + }); + }); + + describe('alertsToReminders / remindersToAlerts', () => { + it('extracts and de-dupes reminders, sorted by lead time desc', () => { + const reminders = alertsToReminders({ + a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' }, + b: { trigger: { '@type': 'OffsetTrigger', offset: '-P1D' }, action: 'display' }, + c: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' }, + }); + expect(reminders).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 15 }]); + }); + + it('builds an alerts map from reminders', () => { + const alerts = remindersToAlerts([{ minutesBefore: 15 }, { minutesBefore: 0 }]); + expect(alerts && Object.keys(alerts)).toHaveLength(2); + const offsets = Object.values(alerts!).map((a) => a.trigger.offset); + expect(offsets).toContain('-PT15M'); + expect(offsets).toContain('PT0S'); + }); + + it('returns undefined for an empty reminder list', () => { + expect(remindersToAlerts([])).toBeUndefined(); + }); + + it('round-trips alerts → reminders → alerts', () => { + const reminders = [{ minutesBefore: 60 }, { minutesBefore: 10 }]; + const back = alertsToReminders(remindersToAlerts(reminders)); + expect(back).toEqual([{ minutesBefore: 60 }, { minutesBefore: 10 }]); + }); + }); + + describe('formatReminder', () => { + it('formats common offsets', () => { + expect(formatReminder(0)).toBe('At time of event'); + expect(formatReminder(15)).toBe('15 minutes before'); + expect(formatReminder(60)).toBe('1 hour before'); + expect(formatReminder(60 * 24)).toBe('1 day before'); + expect(formatReminder(60 * 24 * 7)).toBe('1 week before'); + }); + }); +}); diff --git a/src/lib/__tests__/vcard.test.ts b/src/lib/__tests__/vcard.test.ts new file mode 100644 index 00000000..f64b7d04 --- /dev/null +++ b/src/lib/__tests__/vcard.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { + parseVCard, + generateVCard, + contactToVCard, + detectDuplicates, +} from '../vcard'; +import type { ContactCard } from '../../api/types'; + +describe('parseVCard', () => { + it('parses a basic 3.0 card with name, email, and phone', () => { + const vcf = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:Jane Doe', + 'N:Doe;Jane;;;', + 'EMAIL;TYPE=WORK:jane@example.com', + 'TEL;TYPE=CELL:+1-555-0100', + 'END:VCARD', + ].join('\r\n'); + + const [card] = parseVCard(vcf); + expect(card).toBeTruthy(); + const emails = Object.values(card.emails ?? {}); + expect(emails[0].address).toBe('jane@example.com'); + expect(emails[0].contexts).toEqual({ work: true }); + const phones = Object.values(card.phones ?? {}); + expect(phones[0].number).toBe('+1-555-0100'); + expect(phones[0].features).toEqual({ cell: true }); + const given = card.name?.components?.find((c) => c.kind === 'given')?.value; + const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value; + expect(given).toBe('Jane'); + expect(surname).toBe('Doe'); + }); + + it('parses multiple cards in one file', () => { + const vcf = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'FN:A', 'EMAIL:a@x.com', 'END:VCARD', + 'BEGIN:VCARD', 'VERSION:3.0', 'FN:B', 'EMAIL:b@x.com', 'END:VCARD', + ].join('\r\n'); + expect(parseVCard(vcf)).toHaveLength(2); + }); + + it('unfolds folded lines and decodes escaped values', () => { + const vcf = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:Long Name', + 'NOTE:line one\\nline two\\, still going', + 'END:VCARD', + ].join('\r\n'); + const [card] = parseVCard(vcf); + const note = Object.values(card.notes ?? {})[0]?.note; + expect(note).toBe('line one\nline two, still going'); + }); + + it('parses CATEGORIES into keywords', () => { + const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tagged\r\nEMAIL:t@x.com\r\nCATEGORIES:Friends,VIP\r\nEND:VCARD'; + const [card] = parseVCard(vcf); + expect(card.keywords).toEqual({ Friends: true, VIP: true }); + }); + + it('skips cards with neither a name nor an email', () => { + const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nNOTE:orphan\r\nEND:VCARD'; + expect(parseVCard(vcf)).toHaveLength(0); + }); + + it('strips mailto:/tel: URI schemes', () => { + const vcf = 'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:U\r\nEMAIL:mailto:u@x.com\r\nTEL:tel:+15550111\r\nEND:VCARD'; + const [card] = parseVCard(vcf); + expect(Object.values(card.emails ?? {})[0].address).toBe('u@x.com'); + expect(Object.values(card.phones ?? {})[0].number).toBe('+15550111'); + }); +}); + +describe('generateVCard', () => { + it('round-trips name and email through parse → generate → parse', () => { + const original = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nN:Doe;Jane;;;\r\nEMAIL;TYPE=WORK:jane@example.com\r\nEND:VCARD'; + const [parsed] = parseVCard(original); + const generated = generateVCard([parsed]); + expect(generated).toContain('FN:Jane Doe'); + expect(generated).toContain('EMAIL;TYPE=WORK:jane@example.com'); + + const [reparsed] = parseVCard(generated); + expect(Object.values(reparsed.emails ?? {})[0].address).toBe('jane@example.com'); + }); + + it('contactToVCard emits a single BEGIN/END block', () => { + const card: ContactCard = { + id: 'c1', + addressBookIds: {}, + name: { full: 'Solo', components: [{ kind: 'given', value: 'Solo' }] }, + emails: { e0: { address: 'solo@x.com' } }, + }; + const out = contactToVCard(card); + expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1); + expect(out.match(/END:VCARD/g)).toHaveLength(1); + }); +}); + +describe('detectDuplicates', () => { + const existing: ContactCard[] = [ + { id: 'x1', addressBookIds: {}, emails: { e0: { address: 'dupe@x.com' } } }, + ]; + + it('flags an incoming contact that shares an email (case-insensitive)', () => { + const incoming: ContactCard[] = [ + { id: 'i0', addressBookIds: {}, emails: { e0: { address: 'fresh@x.com' } } }, + { id: 'i1', addressBookIds: {}, emails: { e0: { address: 'DUPE@x.com' } } }, + ]; + const dupes = detectDuplicates(existing, incoming); + expect(dupes.has(0)).toBe(false); + expect(dupes.get(1)).toBe('x1'); + }); + + it('returns an empty map when nothing overlaps', () => { + const incoming: ContactCard[] = [ + { id: 'i0', addressBookIds: {}, emails: { e0: { address: 'new@x.com' } } }, + ]; + expect(detectDuplicates(existing, incoming).size).toBe(0); + }); +}); diff --git a/src/lib/birthday-calendar.ts b/src/lib/birthday-calendar.ts new file mode 100644 index 00000000..f4363ee9 --- /dev/null +++ b/src/lib/birthday-calendar.ts @@ -0,0 +1,117 @@ +import { eachYearOfInterval, parseISO } from 'date-fns'; +import type { Calendar, CalendarEvent, ContactCard, AnniversaryDate } from '../api/types'; +import { getContactDisplayName } from './contact-utils'; + +export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__'; +export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow + +// A read-only virtual calendar holding generated birthday events. +export function createBirthdayCalendar(name = 'Birthdays'): Calendar { + return { + id: BIRTHDAY_CALENDAR_ID, + name, + color: BIRTHDAY_CALENDAR_COLOR, + sortOrder: 999, + isSubscribed: true, + isVisible: true, + isDefault: false, + myRights: { + mayReadItems: true, + mayWriteAll: false, + mayWriteOwn: false, + mayWrite: false, + mayDelete: false, + mayRSVP: false, + }, + }; +} + +function parseBirthdayDate(date: AnniversaryDate): { month: number; day: number; year?: number } | null { + if (typeof date === 'string') { + if (date.startsWith('--')) { + const m = date.match(/^--(\d{2})-(\d{2})$/); + return m ? { month: parseInt(m[1], 10), day: parseInt(m[2], 10) } : null; + } + const parsed = parseISO(date); + if (!isNaN(parsed.getTime())) { + return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() }; + } + return null; + } + if ('utc' in date && date['@type'] === 'Timestamp') { + const parsed = parseISO(date.utc); + if (!isNaN(parsed.getTime())) { + return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() }; + } + return null; + } + // PartialDate + if (date.month && date.day) { + return { month: date.month, day: date.day, year: date.year || undefined }; + } + return null; +} + +// Generate virtual all-day birthday events for the given range from contacts +// that carry a `birth` anniversary. +export function generateBirthdayEvents( + contacts: ContactCard[], + rangeStart: string, + rangeEnd: string, +): CalendarEvent[] { + const events: CalendarEvent[] = []; + const start = parseISO(rangeStart); + const end = parseISO(rangeEnd); + if (isNaN(start.getTime()) || isNaN(end.getTime())) return events; + + const years = eachYearOfInterval({ start, end }); + const endYear = end.getFullYear(); + if (!years.some((y) => y.getFullYear() === endYear)) { + years.push(new Date(endYear, 0, 1)); + } + + for (const contact of contacts) { + if (!contact.anniversaries) continue; + for (const [key, anniversary] of Object.entries(contact.anniversaries)) { + if (anniversary.kind !== 'birth') continue; + const parsed = parseBirthdayDate(anniversary.date); + if (!parsed) continue; + const displayName = getContactDisplayName(contact); + if (!displayName) continue; + + for (const yearDate of years) { + const year = yearDate.getFullYear(); + // Feb 29 in a non-leap year: clamp to Feb 28. + let occMonth = parsed.month; + let occDay = parsed.day; + const occurrence = new Date(year, occMonth - 1, occDay); + if (occurrence.getMonth() !== occMonth - 1) { + occurrence.setDate(0); + occMonth = occurrence.getMonth() + 1; + occDay = occurrence.getDate(); + } + if (occurrence < start || occurrence > end) continue; + + const mm = String(occMonth).padStart(2, '0'); + const dd = String(occDay).padStart(2, '0'); + const age = parsed.year ? year - parsed.year : undefined; + const ageText = age && age > 0 ? ` (${age})` : ''; + + events.push({ + id: `birthday-${contact.id}-${key}-${year}`, + '@type': 'Event', + uid: `birthday-${contact.id}-${key}`, + calendarIds: { [BIRTHDAY_CALENDAR_ID]: true }, + title: `🎂 ${displayName}${ageText}`, + start: `${year}-${mm}-${dd}T00:00:00`, + duration: 'P1D', + showWithoutTime: true, + status: 'confirmed', + freeBusyStatus: 'free', + color: BIRTHDAY_CALENDAR_COLOR, + }); + } + } + } + return events; +} diff --git a/src/lib/calendar-alerts.ts b/src/lib/calendar-alerts.ts new file mode 100644 index 00000000..0226009b --- /dev/null +++ b/src/lib/calendar-alerts.ts @@ -0,0 +1,103 @@ +import type { Alert, CalendarEvent } from '../api/types'; + +// A reminder, expressed as "minutes before the event start". 0 means "at the +// time of the event". Negative values (reminder *after* start) are preserved +// on round-trip but the picker only offers non-negative presets. +export interface Reminder { + /** Minutes before start. 0 = at time of event. */ + minutesBefore: number; +} + +const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; + +// Parse an ISO 8601 duration offset ("-PT15M", "-P1D", "PT0S") to minutes. +// Positive return value = minutes *before* the anchor (JSCalendar offsets for +// reminders are negative, e.g. "-PT15M" fires 15 min before start). +export function offsetToMinutesBefore(offset: string | undefined): number | null { + if (!offset) return null; + const m = DURATION_RE.exec(offset); + if (!m) return null; + const negative = m[1] === '-'; + const weeks = parseInt(m[2] || '0', 10); + const days = parseInt(m[3] || '0', 10); + const hours = parseInt(m[4] || '0', 10); + const minutes = parseInt(m[5] || '0', 10); + const seconds = parseInt(m[6] || '0', 10); + const totalMinutes = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + minutes + Math.round(seconds / 60); + // A negative offset (fires before start) is a positive "minutesBefore". + // `|| 0` collapses -0 to 0 so callers get a clean "at time of event". + return (negative ? totalMinutes : -totalMinutes) || 0; +} + +export function minutesBeforeToOffset(minutesBefore: number): string { + if (minutesBefore === 0) return 'PT0S'; + const sign = minutesBefore > 0 ? '-' : ''; + let mins = Math.abs(minutesBefore); + const days = Math.floor(mins / (24 * 60)); + mins -= days * 24 * 60; + const hours = Math.floor(mins / 60); + mins -= hours * 60; + let out = 'P'; + if (days > 0) out += `${days}D`; + if (hours > 0 || mins > 0) { + out += 'T'; + if (hours > 0) out += `${hours}H`; + if (mins > 0) out += `${mins}M`; + } + if (out === 'P') out = 'PT0M'; + return `${sign}${out}`; +} + +// Pull the list of reminders out of an event's `alerts` map. Only display-style +// offset alerts relative to start are surfaced in the editor; anything exotic is +// dropped from the UI list (but the event keeps it until the user saves). +export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] { + if (!alerts) return []; + const out: Reminder[] = []; + for (const alert of Object.values(alerts)) { + const trigger = alert?.trigger; + if (!trigger) continue; + if (trigger.offset === undefined) continue; // skip AbsoluteTriggers in the picker + const minutesBefore = offsetToMinutesBefore(trigger.offset); + if (minutesBefore === null) continue; + out.push({ minutesBefore }); + } + // De-dupe + sort soonest-to-event last (largest lead time first). + const seen = new Set(); + return out + .filter((r) => (seen.has(r.minutesBefore) ? false : (seen.add(r.minutesBefore), true))) + .sort((a, b) => b.minutesBefore - a.minutesBefore); +} + +export function remindersToAlerts(reminders: Reminder[]): Record | undefined { + if (reminders.length === 0) return undefined; + const alerts: Record = {}; + reminders.forEach((r, i) => { + alerts[`reminder-${i + 1}`] = { + trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) }, + action: 'display', + }; + }); + return alerts; +} + +export function formatReminder(minutesBefore: number): string { + if (minutesBefore === 0) return 'At time of event'; + if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`; + if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`; + if (minutesBefore < 60 * 24) { + const h = minutesBefore / 60; + return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`; + } + if (minutesBefore < 60 * 24 * 7) { + const d = minutesBefore / (60 * 24); + return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`; + } + const w = minutesBefore / (60 * 24 * 7); + return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`; +} + +// Presets offered by the reminder picker (minutes before start). +export const REMINDER_PRESETS: number[] = [ + 0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7, +]; diff --git a/src/lib/calendar-invitation.ts b/src/lib/calendar-invitation.ts new file mode 100644 index 00000000..c0b4a8ce --- /dev/null +++ b/src/lib/calendar-invitation.ts @@ -0,0 +1,148 @@ +import type { CalendarEvent, Participant, Email, Attachment } from '../api/types'; + +// ─── Address helpers ───────────────────────────────────── + +export function normalizeEmail(value?: string | null): string | null { + if (!value) return null; + const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase(); + return normalized || null; +} + +export function getParticipantEmail(p: Participant): string | null { + const direct = normalizeEmail(p.email); + if (direct) return direct; + if (p.calendarAddress) { + const addr = normalizeEmail(p.calendarAddress); + if (addr) return addr; + } + if (p.sendTo) { + for (const addr of Object.values(p.sendTo)) { + const n = normalizeEmail(addr); + if (n) return n; + } + } + return null; +} + +export function isOrganizerParticipant(p: Participant): boolean { + return Boolean(p.roles?.owner || p.roles?.chair); +} + +export function getOrganizerEmail(event: Partial): string | null { + if (event.participants) { + for (const p of Object.values(event.participants)) { + if (isOrganizerParticipant(p)) { + const e = getParticipantEmail(p); + if (e) return e; + } + } + } + if (event.organizerCalendarAddress) return normalizeEmail(event.organizerCalendarAddress); + return null; +} + +export function getOrganizerName(event: Partial): string | null { + if (event.participants) { + for (const p of Object.values(event.participants)) { + if (isOrganizerParticipant(p)) return p.name || getParticipantEmail(p); + } + if (event.organizerCalendarAddress) { + for (const p of Object.values(event.participants)) { + if (p.calendarAddress === event.organizerCalendarAddress) return p.name || getParticipantEmail(p); + } + } + } + return getOrganizerEmail(event); +} + +// Find the participant entry that matches one of the given user emails. +export function findParticipantByEmail( + event: Partial, + emails: string[], +): { id: string; participant: Participant } | null { + if (!event.participants || emails.length === 0) return null; + const wanted = new Set(emails.map((e) => e.toLowerCase()).filter(Boolean)); + for (const [id, p] of Object.entries(event.participants)) { + const e = getParticipantEmail(p); + if (e && wanted.has(e)) return { id, participant: p }; + } + return null; +} + +// `replyTo` to attach to an RSVP so the server knows where to deliver the iTIP +// reply (Stalwart exposes the organizer as organizerCalendarAddress). +export function buildReplyTo(event: Partial): Record | null { + if (event.replyTo) return event.replyTo; + if (event.organizerCalendarAddress) { + const addr = event.organizerCalendarAddress.startsWith('mailto:') + ? event.organizerCalendarAddress + : `mailto:${event.organizerCalendarAddress}`; + return { imip: addr }; + } + return null; +} + +// ─── iMIP method detection ─────────────────────────────── + +export type InvitationMethod = + | 'publish' | 'request' | 'reply' | 'add' + | 'cancel' | 'refresh' | 'counter' | 'declinecounter' | 'unknown'; + +const KNOWN_METHODS = new Set([ + 'publish', 'request', 'reply', 'add', 'cancel', 'refresh', 'counter', 'declinecounter', +]); + +function normalizeMethod(value?: string | null): InvitationMethod { + if (!value) return 'unknown'; + const v = value.trim().toLowerCase(); + return KNOWN_METHODS.has(v as InvitationMethod) ? (v as InvitationMethod) : 'unknown'; +} + +// JMAP strips Content-Type params (RFC 8621), so `text/calendar; method=REQUEST` +// arrives as `text/calendar`. The raw ICS METHOD line is the reliable source. +export function extractMethodFromRawIcs(rawText: string): InvitationMethod { + const m = rawText.match(/^METHOD:(\S+)/m); + return m ? normalizeMethod(m[1]) : 'unknown'; +} + +function looksLikeReply(event: Partial): boolean { + if (!event.participants) return false; + return Object.values(event.participants).some( + (p) => + p.roles?.attendee && + !isOrganizerParticipant(p) && + (p.participationStatus !== 'needs-action' || !!p.participationComment), + ); +} + +export function inferInvitationMethod(event: Partial): InvitationMethod { + if (event.status === 'cancelled') return 'cancel'; + if (looksLikeReply(event)) return 'reply'; + if (event.participants && Object.keys(event.participants).length > 0) { + if (Object.values(event.participants).some(isOrganizerParticipant) || event.organizerCalendarAddress) { + return 'request'; + } + } + return 'unknown'; +} + +// ─── Calendar attachment detection ─────────────────────── + +const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']); + +function isCalendarType(type?: string | null): boolean { + if (!type) return false; + const base = type.split(';')[0].trim().toLowerCase(); + return CAL_MIME.has(base); +} + +export function findCalendarAttachment(email: Pick): Attachment | null { + if (!email.attachments) return null; + for (const att of email.attachments) { + const name = att.name?.toLowerCase() || ''; + if (isCalendarType(att.type) || name.endsWith('.ics') || name.endsWith('.ical')) { + return att; + } + } + return null; +} diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 00000000..d0ce302d --- /dev/null +++ b/src/lib/uuid.ts @@ -0,0 +1,20 @@ +// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when +// available (Hermes/React Native 0.81 exposes it), falling back to a +// Math.random implementation. These IDs are only used as temporary client-side +// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so +// the Math.random fallback's weaker entropy is acceptable. +export function generateUUID(): string { + const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; + if (c?.randomUUID) { + try { + return c.randomUUID(); + } catch { + // fall through to the manual implementation + } + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { + const r = (Math.random() * 16) | 0; + const v = ch === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/src/lib/vcard.ts b/src/lib/vcard.ts index a3584e26..ffd7ada0 100644 --- a/src/lib/vcard.ts +++ b/src/lib/vcard.ts @@ -1,174 +1,1261 @@ import type { ContactCard, - ContactAnniversary, + NameComponent, + ContactOnlineService, AnniversaryDate, PartialDate, - Timestamp, } from '../api/types'; -import { - getContactDisplayName, - getContactKeywords, -} from './contact-utils'; +import { generateUUID } from './uuid'; -function escape(value: string): string { - return value - .replace(/\\/g, '\\\\') - .replace(/\n/g, '\\n') - .replace(/\r/g, '') - .replace(/,/g, '\\,') - .replace(/;/g, '\\;'); -} - -function fold(line: string): string { - if (line.length <= 75) return line; - const chunks: string[] = []; - let i = 0; - while (i < line.length) { - const chunk = line.slice(i, i + (i === 0 ? 75 : 74)); - chunks.push(chunk); - i += chunk.length; - } - return chunks.join('\r\n '); -} - -function contextsToTypeParam(contexts?: Record): string { - if (!contexts) return ''; - const types = Object.keys(contexts).filter((k) => contexts[k]); - if (types.length === 0) return ''; - return `;TYPE=${types.join(',')}`; -} - -function anniversaryDateToVCardDate(date: AnniversaryDate | undefined): string { - if (!date) return ''; +// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string +function anniversaryDateToVcardString(date: AnniversaryDate): string { if (typeof date === 'string') return date; - const ts = date as Timestamp; - if (ts['@type'] === 'Timestamp' && typeof ts.utc === 'string') return ts.utc; - const pd = date as PartialDate; - const y = pd.year ? String(pd.year).padStart(4, '0') : ''; - const m = pd.month ? String(pd.month).padStart(2, '0') : ''; - const d = pd.day ? String(pd.day).padStart(2, '0') : ''; - if (!y && !m && !d) return ''; - return `${y || '--'}${m || '--'}${d || '--'}`.replace(/^--/, '--'); -} - -function findBirthday(contact: ContactCard): ContactAnniversary | undefined { - if (!contact.anniversaries) return undefined; - return Object.values(contact.anniversaries).find((a) => a.kind === 'birth'); -} - -export function contactToVCard(contact: ContactCard): string { - const lines: string[] = []; - lines.push('BEGIN:VCARD'); - lines.push('VERSION:3.0'); - - const displayName = getContactDisplayName(contact) || 'Unnamed'; - lines.push(fold(`FN:${escape(displayName)}`)); - - const name = contact.name; - if (name?.components && name.components.length > 0) { - const parts: Record = { - surname: '', - given: '', - middle: '', - prefix: '', - suffix: '', - }; - for (const c of name.components) { - if (c.kind in parts) parts[c.kind] = c.value; + if (date && typeof date === 'object') { + if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) { + return (date as { utc: string }).utc.split('T')[0]; } - lines.push(fold( - `N:${escape(parts.surname)};${escape(parts.given)};${escape(parts.middle)};${escape(parts.prefix)};${escape(parts.suffix)}`, - )); - } else if (name?.full) { - lines.push(fold(`N:;${escape(name.full)};;;`)); + const pd = date as PartialDate; + if (pd.year && pd.month && pd.day) { + return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; + } + if (pd.month && pd.day) { + return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; + } + if (pd.year && pd.month) { + return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`; + } + if (pd.year) return String(pd.year); + } + return String(date); +} + +const VCARD_SEX_TO_GENDER: Record = { + M: 'masculine', + F: 'feminine', + O: 'other', + N: 'none', + U: 'unknown', +}; + +const GENDER_TO_VCARD_SEX: Record = { + masculine: 'M', + feminine: 'F', + other: 'O', + none: 'N', + unknown: 'U', +}; + +function vcardSexToGrammaticalGender(sex: string): string { + return VCARD_SEX_TO_GENDER[sex.toUpperCase()] || sex.toLowerCase(); +} + +function grammaticalGenderToVcardSex(gender: string): string { + return GENDER_TO_VCARD_SEX[gender.toLowerCase()] || ''; +} + +function unfoldLines(vcf: string): string { + // Normalize line endings first, then unfold continuation lines (RFC 6350 §3.2). + // Continuation lines start with a single SPACE or TAB; we must handle both + // CRLF (RFC-canonical) and LF-only files (common from Unix exporters). + return vcf + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\n[ \t]/g, ''); +} + +// RFC 6868 parameter value encoding - used inside parameter values only. +// Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE. +function decodeParamValue(s: string): string { + let out = ''; + for (let i = 0; i < s.length; i++) { + if (s[i] === '^' && i + 1 < s.length) { + const next = s[i + 1]; + if (next === 'n') { out += '\n'; i++; continue; } + if (next === '^') { out += '^'; i++; continue; } + if (next === "'") { out += '"'; i++; continue; } + } + out += s[i]; + } + return out; +} + +// Split on delim, respecting DQUOTE-quoted spans (RFC 6350 §3.3 / §5). +function splitRespectingQuotes(s: string, delim: string): string[] { + const out: string[] = []; + let buf = ''; + let inQuote = false; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"') { + inQuote = !inQuote; + buf += ch; + continue; + } + if (ch === delim && !inQuote) { + out.push(buf); + buf = ''; + continue; + } + buf += ch; + } + out.push(buf); + return out; +} + +// Find the first ":" outside of a DQUOTE-quoted parameter value. +// Returns -1 when none. Needed because property params may carry quoted +// values that contain colons (e.g. ADR;LABEL="Suite 100:..." or X- params). +function findValueColon(line: string): number { + let inQuote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { inQuote = !inQuote; continue; } + if (ch === ':' && !inQuote) return i; + } + return -1; +} + +// vCard properties may carry a group prefix: "item1.EMAIL:foo@bar". +// Strip the prefix and return the bare property name + params component. +function stripGroupPrefix(keyPart: string): string { + const dot = keyPart.indexOf('.'); + if (dot < 0) return keyPart; + const before = keyPart.substring(0, dot); + // Only treat as group if the segment before the dot has no ";" (which would + // indicate it's actually a param boundary) and matches the RFC 6350 group + // grammar (ALPHA / DIGIT / "-"). + if (before.includes(';')) return keyPart; + if (!/^[A-Za-z0-9-]+$/.test(before)) return keyPart; + return keyPart.substring(dot + 1); +} + +// Strip URI scheme prefix from a value (e.g. "tel:+1-555" → "+1-555"). +function stripUriScheme(val: string, scheme: string): string { + const prefix = `${scheme}:`; + if (val.toLowerCase().startsWith(prefix)) return val.substring(prefix.length); + return val; +} + +function parsePrefParam(params: Record): number | undefined { + if (params.PREF) { + const n = parseInt(params.PREF, 10); + if (!Number.isNaN(n)) return n; + } + // vCard 3.0 style: TYPE=PREF (no numeric value) + if (params.TYPE && /\bPREF\b/i.test(params.TYPE)) return 1; + return undefined; +} + +// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues +// onto the next line. This is distinct from RFC 5545/6350 line folding (which +// uses leading whitespace and is already handled in unfoldLines). Only merge +// when the originating line declares ENCODING=QUOTED-PRINTABLE so we don't +// accidentally splice unrelated lines. +function joinQpSoftBreaks(lines: string[]): string[] { + const result: string[] = []; + let i = 0; + while (i < lines.length) { + let line = lines[i]; + if (/;ENCODING=QUOTED-PRINTABLE/i.test(line)) { + while (line.endsWith('=') && i + 1 < lines.length) { + i++; + line = line.slice(0, -1) + lines[i]; + } + } + result.push(line); + i++; + } + return result; +} + +// Decode a UTF-8 byte array to a string. Uses the platform TextDecoder when +// present (faster, charset-aware) and otherwise falls back to a manual UTF-8 +// decode so this works under Hermes without a TextDecoder polyfill. +function decodeBytes(bytes: number[], charset?: string): string { + const Decoder = (globalThis as { TextDecoder?: typeof TextDecoder }).TextDecoder; + if (Decoder) { + try { + return new Decoder(charset || 'utf-8').decode(new Uint8Array(bytes)); + } catch { + try { + return new Decoder('utf-8').decode(new Uint8Array(bytes)); + } catch { + // fall through to manual decode + } + } + } + // Minimal UTF-8 decoder fallback. + let out = ''; + let i = 0; + while (i < bytes.length) { + const b0 = bytes[i]; + if (b0 < 0x80) { + out += String.fromCharCode(b0); + i += 1; + } else if (b0 >= 0xc0 && b0 < 0xe0 && i + 1 < bytes.length) { + out += String.fromCharCode(((b0 & 0x1f) << 6) | (bytes[i + 1] & 0x3f)); + i += 2; + } else if (b0 >= 0xe0 && b0 < 0xf0 && i + 2 < bytes.length) { + out += String.fromCharCode( + ((b0 & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f), + ); + i += 3; + } else if (b0 >= 0xf0 && i + 3 < bytes.length) { + const cp = ((b0 & 0x07) << 18) | ((bytes[i + 1] & 0x3f) << 12) + | ((bytes[i + 2] & 0x3f) << 6) | (bytes[i + 3] & 0x3f); + const off = cp - 0x10000; + out += String.fromCharCode(0xd800 + (off >> 10), 0xdc00 + (off & 0x3ff)); + i += 4; + } else { + out += String.fromCharCode(b0); + i += 1; + } + } + return out; +} + +function decodeQuotedPrintable(input: string, charset?: string): string { + const cleaned = input.replace(/=\r?\n/g, ''); + const bytes: number[] = []; + let i = 0; + while (i < cleaned.length) { + const ch = cleaned[i]; + if (ch === '=' && i + 2 < cleaned.length) { + const hex = cleaned.substring(i + 1, i + 3); + if (/^[0-9A-Fa-f]{2}$/.test(hex)) { + bytes.push(parseInt(hex, 16)); + i += 3; + continue; + } + } + bytes.push(cleaned.charCodeAt(i) & 0xff); + i += 1; + } + return decodeBytes(bytes, charset); +} + +function decodeValue(raw: string): string { + return raw + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); +} + +function encodeValue(val: string): string { + return val + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); +} + +function parseParams(paramStr: string): Record { + const params: Record = {}; + if (!paramStr) return params; + const parts = splitRespectingQuotes(paramStr, ';'); + for (const part of parts) { + if (!part) continue; + const eq = part.indexOf('='); + if (eq > 0) { + const name = part.substring(0, eq).toUpperCase(); + const rawVal = part.substring(eq + 1).replace(/^"(.*)"$/, '$1'); + params[name] = decodeParamValue(rawVal); + } else { + const upper = part.toUpperCase(); + if (upper === 'QUOTED-PRINTABLE' || upper === 'BASE64') { + params.ENCODING = upper; + } else if (['WORK', 'HOME', 'CELL', 'FAX', 'VOICE', 'PREF', 'PAGER', 'VIDEO', 'TEXT', 'TEXTPHONE'].includes(upper)) { + params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper; + } + } + } + return params; +} + +const PHONE_FEATURE_TYPES = new Set(['CELL', 'FAX', 'VOICE', 'PAGER', 'VIDEO', 'TEXT', 'TEXTPHONE']); + +function typeToPhoneFeatures(typeStr: string | undefined): Record | undefined { + if (!typeStr) return undefined; + const types = typeStr.toUpperCase().split(','); + const features: Record = {}; + for (const t of types) { + if (PHONE_FEATURE_TYPES.has(t)) { + features[t.toLowerCase()] = true; + } + } + return Object.keys(features).length > 0 ? features : undefined; +} + +function typeToContext(typeStr: string | undefined): Record | undefined { + if (!typeStr) return undefined; + const types = typeStr.toUpperCase().split(','); + const ctx: Record = {}; + if (types.includes('WORK')) ctx.work = true; + if (types.includes('HOME')) ctx.private = true; + if (!ctx.work && !ctx.private) return undefined; + return ctx; +} + +function contextToType(contexts: Record | undefined): string { + if (!contexts) return ''; + if (contexts.work) return 'WORK'; + if (contexts.private) return 'HOME'; + return ''; +} + +export function parseVCard(vcfString: string): ContactCard[] { + const text = unfoldLines(vcfString); + const lines = joinQpSoftBreaks(text.split('\n')); + const contacts: ContactCard[] = []; + let current: Record | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.toUpperCase() === 'BEGIN:VCARD') { + current = {}; + continue; + } + + if (trimmed.toUpperCase() === 'END:VCARD') { + if (current) { + const card = buildContact(current); + if (card) contacts.push(card); + } + current = null; + continue; + } + + if (current) { + const colonIdx = findValueColon(trimmed); + if (colonIdx < 1) continue; + const keyPart = stripGroupPrefix(trimmed.substring(0, colonIdx)); + const value = trimmed.substring(colonIdx + 1); + if (!current[keyPart]) current[keyPart] = []; + current[keyPart].push(value); + } + } + + return contacts; +} + +function buildContact(raw: Record): ContactCard | null { + const id = `import-${generateUUID()}`; + const card: ContactCard = { id, addressBookIds: {} }; + // Deferred BIRTHPLACE/DEATHPLACE values - attach to anniversary at end, + // because the BDAY/DEATHDATE entry may appear in any order. + let birthPlace: string | undefined; + let deathPlace: string | undefined; + + for (const [fullKey, values] of Object.entries(raw)) { + // splitRespectingQuotes so a quoted param value containing ";" survives. + const segments = splitRespectingQuotes(fullKey, ';'); + const propName = (segments.shift() || '').toUpperCase(); + const paramStr = segments.join(';'); + const params = parseParams(paramStr); + const pref = parsePrefParam(params); + + const isQuotedPrintable = params.ENCODING?.toUpperCase() === 'QUOTED-PRINTABLE'; + + for (const rawValue of values) { + const decoded = isQuotedPrintable + ? decodeQuotedPrintable(rawValue, params.CHARSET) + : rawValue; + const val = decodeValue(decoded); + + switch (propName) { + case 'FN': + if (!card.name) { + const parts = val.split(' '); + const components: NameComponent[] = []; + if (parts.length >= 2) { + components.push({ kind: 'given', value: parts[0] }); + components.push({ kind: 'surname', value: parts.slice(1).join(' ') }); + } else if (parts.length === 1) { + components.push({ kind: 'given', value: parts[0] }); + } + card.name = { components, isOrdered: true }; + } + break; + + case 'N': { + // vCard N: family;given;additional;prefix;suffix (RFC 6350 §6.2.2) + // Mapped to JSContact-standard kinds (RFC 9553 §2.2.1): + // prefix→title, additional→given2, suffix→generation. + // Pushed in natural display order so `isOrdered: true` renders correctly. + const nParts = val.split(';'); + const components: NameComponent[] = []; + if (nParts[3]) components.push({ kind: 'title', value: nParts[3] }); + if (nParts[1]) components.push({ kind: 'given', value: nParts[1] }); + if (nParts[2]) components.push({ kind: 'given2', value: nParts[2] }); + if (nParts[0]) components.push({ kind: 'surname', value: nParts[0] }); + if (nParts[4]) components.push({ kind: 'generation', value: nParts[4] }); + if (components.length > 0) { + card.name = { components, isOrdered: true }; + } + break; + } + + case 'EMAIL': { + if (!card.emails) card.emails = {}; + const idx = Object.keys(card.emails).length; + card.emails[`e${idx}`] = { + address: stripUriScheme(val, 'mailto'), + contexts: typeToContext(params.TYPE), + label: params['X-ABLABEL'] || undefined, + pref, + }; + break; + } + + case 'TEL': { + if (!card.phones) card.phones = {}; + const idx = Object.keys(card.phones).length; + card.phones[`p${idx}`] = { + // vCard 4.0 TEL is a URI value (RFC 6350 §6.4.1); strip the + // "tel:" scheme for storage as a bare number. + number: stripUriScheme(val, 'tel'), + contexts: typeToContext(params.TYPE), + features: typeToPhoneFeatures(params.TYPE), + label: params['X-ABLABEL'] || undefined, + pref, + }; + break; + } + + case 'ORG': { + if (!card.organizations) card.organizations = {}; + const orgParts = val.split(';').filter(Boolean); + const idx = Object.keys(card.organizations).length; + card.organizations[`o${idx}`] = { + name: orgParts[0], + units: orgParts.slice(1).map((u) => ({ name: u })), + }; + break; + } + + case 'ADR': { + if (!card.addresses) card.addresses = {}; + const adrParts = val.split(';'); + const idx = Object.keys(card.addresses).length; + card.addresses[`a${idx}`] = { + street: adrParts[2] || undefined, + locality: adrParts[3] || undefined, + region: adrParts[4] || undefined, + postcode: adrParts[5] || undefined, + country: adrParts[6] || undefined, + // vCard 4.0 (RFC 9554 §3.2): CC param carries ISO country code, + // and LABEL/GEO/TZ params attach directly to the ADR. + countryCode: params.CC || undefined, + fullAddress: params.LABEL || undefined, + coordinates: params.GEO ? stripUriScheme(params.GEO, 'geo') : undefined, + timeZone: params.TZ || undefined, + contexts: typeToContext(params.TYPE), + label: params['X-ABLABEL'] || undefined, + pref, + }; + break; + } + + case 'NOTE': { + if (!card.notes) card.notes = {}; + const idx = Object.keys(card.notes).length; + card.notes[`n${idx}`] = { note: val }; + break; + } + + case 'NICKNAME': { + if (!card.nicknames) card.nicknames = {}; + card.nicknames.n0 = { name: val }; + break; + } + + case 'UID': + card.uid = val; + break; + + case 'KIND': { + // RFC 6350 §6.1.4 plus RFC 6473 (application). + const k = val.toLowerCase(); + if (k === 'group' || k === 'individual' || k === 'org' + || k === 'location' || k === 'device' || k === 'application') { + card.kind = k; + } + break; + } + + case 'MEMBER': { + if (!card.members) card.members = {}; + const memberUri = val.startsWith('urn:uuid:') ? val.substring(9) : val; + card.members[memberUri] = true; + break; + } + + case 'PHOTO': { + if (!card.media) card.media = {}; + const idx = Object.keys(card.media).length; + const encoding = params.ENCODING?.toUpperCase(); + // vCard 4.0 uses MEDIATYPE; 3.0 reuses TYPE for the image kind. + const mediaType = params.MEDIATYPE || (params.TYPE && params.TYPE.includes('/') ? params.TYPE : (params.TYPE && /^(JPEG|JPG|PNG|GIF|WEBP|HEIC|BMP|SVG)$/i.test(params.TYPE) ? params.TYPE : '')) || ''; + if (encoding === 'B' || encoding === 'BASE64') { + // Inline base64 photo - construct a data URI + const mime = mediaType.includes('/') ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : 'image/jpeg'; + card.media[`m${idx}`] = { + kind: 'photo', + uri: `data:${mime};base64,${rawValue}`, + mediaType: mime, + }; + } else if (val.startsWith('data:') || val.startsWith('http://') || val.startsWith('https://')) { + // vCard 4.0 URI value (data URI or URL) - no ENCODING param. + card.media[`m${idx}`] = { + kind: 'photo', + uri: val, + mediaType: mediaType.includes('/') ? mediaType : undefined, + }; + } + break; + } + + case 'TITLE': { + if (!card.titles) card.titles = {}; + const idx = Object.keys(card.titles).length; + card.titles[`t${idx}`] = { name: val, kind: 'title' }; + break; + } + + case 'ROLE': { + if (!card.titles) card.titles = {}; + const idx = Object.keys(card.titles).length; + card.titles[`t${idx}`] = { name: val, kind: 'role' }; + break; + } + + case 'URL': { + if (!card.onlineServices) card.onlineServices = {}; + const idx = Object.keys(card.onlineServices).length; + card.onlineServices[`u${idx}`] = { + uri: val, + contexts: typeToContext(params.TYPE), + label: params['X-ABLABEL'] + || (params.TYPE?.toLowerCase() === 'home' || params.TYPE?.toLowerCase() === 'work' ? undefined : params.TYPE), + pref, + }; + break; + } + + case 'IMPP': + case 'X-SOCIALPROFILE': + case 'SOCIALPROFILE': { + // RFC 9554 §3.7 introduces SOCIALPROFILE; treat the same as IMPP/X-SOCIALPROFILE. + if (!card.onlineServices) card.onlineServices = {}; + const idx = Object.keys(card.onlineServices).length; + const svc: ContactOnlineService = { + uri: val, + contexts: typeToContext(params.TYPE), + pref, + }; + if (params['X-SERVICE-TYPE']) { + svc.service = params['X-SERVICE-TYPE']; + } else if (params.SERVICE) { + svc.service = params.SERVICE; + } else if ((propName === 'X-SOCIALPROFILE' || propName === 'SOCIALPROFILE') && params.TYPE) { + const typeVal = params.TYPE.toLowerCase(); + if (typeVal !== 'work' && typeVal !== 'home') { + svc.service = params.TYPE; + } + } + if (params['X-USER']) svc.user = params['X-USER']; + if (params['X-ABLABEL']) svc.label = params['X-ABLABEL']; + card.onlineServices[`u${idx}`] = svc; + break; + } + + case 'BDAY': { + if (!card.anniversaries) card.anniversaries = {}; + card.anniversaries.a0 = { kind: 'birth', date: val }; + break; + } + + case 'BIRTHPLACE': { + // RFC 6474 §2.1. Stash the location and attach to the birth + // anniversary at the end of buildContact, since BDAY may appear + // either before or after BIRTHPLACE in the vCard. + birthPlace = val; + break; + } + + case 'ANNIVERSARY': + case 'X-ANNIVERSARY': { + if (!card.anniversaries) card.anniversaries = {}; + const idx = Object.keys(card.anniversaries).length; + card.anniversaries[`a${idx}`] = { kind: 'wedding', date: val }; + break; + } + + case 'DEATHDATE': + case 'X-DEATHDATE': { + if (!card.anniversaries) card.anniversaries = {}; + const idx = Object.keys(card.anniversaries).length; + card.anniversaries[`a${idx}`] = { kind: 'death', date: val }; + break; + } + + case 'DEATHPLACE': { + // RFC 6474 §2.2. + deathPlace = val; + break; + } + + case 'CATEGORIES': { + if (!card.keywords) card.keywords = {}; + const cats = val.split(',').map((cat) => cat.trim()).filter(Boolean); + for (const cat of cats) { + card.keywords[cat] = true; + } + break; + } + + case 'KEY': { + if (!card.cryptoKeys) card.cryptoKeys = {}; + const idx = Object.keys(card.cryptoKeys).length; + card.cryptoKeys[`k${idx}`] = { + uri: val, + mediaType: params.MEDIATYPE || (params.TYPE && params.TYPE.includes('/') ? params.TYPE : undefined), + contexts: typeToContext(params.TYPE), + }; + break; + } + + case 'RELATED': { + if (!card.relatedTo) card.relatedTo = {}; + // RFC 6350 §6.6.6: TYPE may be a comma-separated list (or + // multi-valued via repeated params); convert to relation map. + const relation: Record = {}; + if (params.TYPE) { + for (const t of params.TYPE.split(',')) { + const norm = t.trim().toLowerCase(); + if (norm) relation[norm] = true; + } + } + card.relatedTo[val] = { relation: Object.keys(relation).length > 0 ? relation : undefined }; + break; + } + + case 'LANG': { + if (!card.preferredLanguages) card.preferredLanguages = {}; + const idx = Object.keys(card.preferredLanguages).length; + card.preferredLanguages[`l${idx}`] = { + language: val, + contexts: typeToContext(params.TYPE), + pref, + }; + break; + } + + case 'PRODID': + card.prodId = val; + break; + + case 'REV': + card.updated = val; + break; + + case 'GEO': { + // Store GEO as coordinates on the first address, or create one + if (!card.addresses) card.addresses = {}; + if (Object.keys(card.addresses).length === 0) { + card.addresses.a0 = { coordinates: val }; + } else { + const firstKey = Object.keys(card.addresses)[0]; + card.addresses[firstKey].coordinates = val; + } + break; + } + + case 'TZ': { + if (!card.addresses) card.addresses = {}; + if (Object.keys(card.addresses).length === 0) { + card.addresses.a0 = { timeZone: val }; + } else { + const firstKey = Object.keys(card.addresses)[0]; + card.addresses[firstKey].timeZone = val; + } + break; + } + + case 'GENDER': { + // vCard 4.0 §6.2.7: sex-component[;identity-component]. We map the + // sex letter to JSContact's grammaticalGender and stuff the free- + // form identity into pronouns (a coarse approximation; RFC 9554's + // PRONOUNS / GRAMGENDER, handled below, are preferred when present). + const gParts = val.split(';'); + const sexCode = gParts[0]?.toUpperCase(); + const identityText = gParts[1]; + if (sexCode || identityText) { + if (!card.speakToAs) card.speakToAs = {}; + if (sexCode) { + card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode); + } + if (identityText) { + if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {}; + const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`; + card.speakToAs.pronouns[pkey] = { pronouns: identityText }; + } + } + break; + } + + case 'LOGO': { + if (!card.media) card.media = {}; + const idx = Object.keys(card.media).length; + const encoding = params.ENCODING?.toUpperCase(); + // Prefer MEDIATYPE (vCard 4.0); fall back to TYPE only when it's a + // MIME type or a known image format token (vCard 3.0 idiom). + const mediaType = params.MEDIATYPE || (params.TYPE && (params.TYPE.includes('/') || /^(JPEG|JPG|PNG|GIF|WEBP|SVG)$/i.test(params.TYPE)) ? params.TYPE : ''); + if (encoding === 'B' || encoding === 'BASE64') { + const mime = mediaType.includes('/') ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : 'image/png'; + card.media[`m${idx}`] = { + kind: 'logo', + uri: `data:${mime};base64,${rawValue}`, + mediaType: mime, + }; + } else if (val.startsWith('data:') || val.startsWith('http://') || val.startsWith('https://')) { + card.media[`m${idx}`] = { + kind: 'logo', + uri: val, + mediaType: mediaType.includes('/') ? mediaType : undefined, + }; + } + break; + } + + case 'SOUND': { + if (!card.media) card.media = {}; + const idx = Object.keys(card.media).length; + const encoding = params.ENCODING?.toUpperCase(); + const mediaType = params.MEDIATYPE || (params.TYPE && (params.TYPE.includes('/') || /^(OGG|MP3|WAV|AAC|FLAC)$/i.test(params.TYPE)) ? params.TYPE : ''); + if (encoding === 'B' || encoding === 'BASE64') { + const mime = mediaType.includes('/') ? mediaType : mediaType ? `audio/${mediaType.toLowerCase()}` : 'audio/ogg'; + card.media[`m${idx}`] = { + kind: 'sound', + uri: `data:${mime};base64,${rawValue}`, + mediaType: mime, + }; + } else if (val.startsWith('data:') || val.startsWith('http://') || val.startsWith('https://')) { + card.media[`m${idx}`] = { + kind: 'sound', + uri: val, + mediaType: mediaType.includes('/') ? mediaType : undefined, + }; + } + break; + } + + case 'LABEL': { + // Mailing label (v2.1/3.0) - store as fullAddress on last/new address + if (!card.addresses) card.addresses = {}; + const addrKeys = Object.keys(card.addresses); + if (addrKeys.length > 0) { + const lastKey = addrKeys[addrKeys.length - 1]; + card.addresses[lastKey].fullAddress = val; + } else { + card.addresses.a0 = { fullAddress: val, contexts: typeToContext(params.TYPE) }; + } + break; + } + + case 'CALURI': + card.calendarUri = val; + break; + + case 'CALADRURI': + card.schedulingUri = val; + break; + + case 'FBURL': + card.freeBusyUri = val; + break; + + case 'SOURCE': + card.source = val; + break; + + // ---- RFC 6715 (EXPERTISE / HOBBY / INTEREST / ORG-DIRECTORY) ---- + case 'EXPERTISE': + case 'HOBBY': + case 'INTEREST': { + if (!card.personalInfo) card.personalInfo = {}; + const idx = Object.keys(card.personalInfo).length; + const kind = propName.toLowerCase() as 'expertise' | 'hobby' | 'interest'; + const rawLevel = params.LEVEL?.toLowerCase(); + // RFC 6715 levels: expertise uses beginner/average/expert; hobby/ + // interest use high/medium/low. Normalize all into JSContact's + // high/medium/low triplet. + const levelMap: Record = { + beginner: 'low', average: 'medium', expert: 'high', + low: 'low', medium: 'medium', high: 'high', + }; + const level = rawLevel ? levelMap[rawLevel] : undefined; + card.personalInfo[`i${idx}`] = { kind, value: val, level }; + break; + } + + case 'ORG-DIRECTORY': { + // RFC 6715 §2.4 - directory URI for the contact's organization. + if (!card.directories) card.directories = {}; + const idx = Object.keys(card.directories).length; + card.directories[`d${idx}`] = { + uri: val, + kind: 'directory', + mediaType: params.MEDIATYPE || undefined, + }; + break; + } + + // ---- RFC 8605 (CONTACT-URI) ---- + case 'CONTACT-URI': { + if (!card.links) card.links = {}; + const idx = Object.keys(card.links).length; + card.links[`l${idx}`] = { + uri: val, + kind: 'contact', + pref, + }; + break; + } + + // ---- RFC 9554 vCard 4.0 extensions ---- + case 'CREATED': + card.created = val; + break; + + case 'GRAMGENDER': { + // RFC 9554 §3.4 - grammatical gender (animate/common/feminine/masculine/neuter). + if (!card.speakToAs) card.speakToAs = {}; + card.speakToAs.grammaticalGender = val.toLowerCase(); + break; + } + + case 'PRONOUNS': { + // RFC 9554 §3.5 - free-form pronouns. May appear multiple times. + if (!card.speakToAs) card.speakToAs = {}; + if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {}; + const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`; + card.speakToAs.pronouns[pkey] = { + pronouns: val, + pref, + contexts: typeToContext(params.TYPE), + }; + break; + } + + // Silently swallow purely structural / sync metadata properties so + // they don't appear in any catch-all default. + case 'VERSION': + case 'XML': + case 'CLIENTPIDMAP': + case 'X-ABLABEL': + break; + + default: + break; + } + } + } + + // Attach BIRTHPLACE/DEATHPLACE to the matching anniversary, creating an + // anniversary entry if no BDAY/DEATHDATE was present. + if (birthPlace || deathPlace) { + if (!card.anniversaries) card.anniversaries = {}; + if (birthPlace) { + let birth = Object.values(card.anniversaries).find((a) => a.kind === 'birth'); + if (!birth) { + card.anniversaries.a0 = { kind: 'birth', date: '' }; + birth = card.anniversaries.a0; + } + birth.place = { fullAddress: birthPlace }; + } + if (deathPlace) { + let death = Object.values(card.anniversaries).find((a) => a.kind === 'death'); + if (!death) { + const key = `a${Object.keys(card.anniversaries).length}`; + card.anniversaries[key] = { kind: 'death', date: '' }; + death = card.anniversaries[key]; + } + death.place = { fullAddress: deathPlace }; + } + } + + const hasName = (card.name && (card.name.components?.length ?? 0) > 0) || !!card.name?.full; + const hasEmail = card.emails && Object.keys(card.emails).length > 0; + if (!hasName && !hasEmail && card.kind !== 'group') return null; + + return card; +} + +export function generateVCard(contacts: ContactCard[]): string { + return contacts.map(generateSingleVCard).join('\r\n'); +} + +function generateSingleVCard(contact: ContactCard): string { + const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0']; + + if (contact.uid) { + lines.push(`UID:${contact.uid}`); + } + + if (contact.prodId) { + lines.push(`PRODID:${contact.prodId}`); + } + + if (contact.kind) { + lines.push(`KIND:${contact.kind}`); + } + + if (contact.updated) { + lines.push(`REV:${contact.updated}`); + } + + const components = contact.name?.components || []; + const findKind = (...kinds: string[]) => + components.find((c) => kinds.includes(c.kind))?.value || ''; + const given = findKind('given'); + const surname = findKind('surname'); + // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. + const prefix = findKind('title', 'prefix'); + const suffix = findKind('generation', 'suffix'); + const additional = findKind('given2', 'additional', 'middle'); + + const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(' ') || contact.name?.full || ''; + if (fn) { + lines.push(`FN:${encodeValue(fn)}`); + lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`); } if (contact.nicknames) { - const nick = Object.values(contact.nicknames) - .map((n) => n.name) - .filter(Boolean) - .join(','); - if (nick) lines.push(fold(`NICKNAME:${escape(nick)}`)); + for (const nick of Object.values(contact.nicknames)) { + lines.push(`NICKNAME:${encodeValue(nick.name)}`); + } } if (contact.emails) { for (const email of Object.values(contact.emails)) { if (!email.address) continue; - lines.push(fold(`EMAIL${contextsToTypeParam(email.contexts)}:${escape(email.address)}`)); + const type = contextToType(email.contexts); + const params: string[] = []; + if (type) params.push(`TYPE=${type}`); + if (email.pref) params.push(`PREF=${email.pref}`); + const paramStr = params.length > 0 ? `;${params.join(';')}` : ''; + lines.push(`EMAIL${paramStr}:${email.address}`); } } if (contact.phones) { for (const phone of Object.values(contact.phones)) { if (!phone.number) continue; - lines.push(fold(`TEL${contextsToTypeParam(phone.contexts)}:${escape(phone.number)}`)); - } - } - - if (contact.addresses) { - for (const addr of Object.values(contact.addresses)) { - const street = addr.components?.find((c) => c.kind === 'name')?.value - || addr.components?.filter((c) => c.kind === 'number' || c.kind === 'name') - .map((c) => c.value).join(' ') - || ''; - const locality = addr.components?.find((c) => c.kind === 'locality')?.value || ''; - const region = addr.components?.find((c) => c.kind === 'region')?.value || ''; - const postcode = addr.components?.find((c) => c.kind === 'postcode')?.value || ''; - const country = addr.components?.find((c) => c.kind === 'country')?.value || addr.countryCode || ''; - lines.push(fold( - `ADR${contextsToTypeParam(addr.contexts)}:;;${escape(street)};${escape(locality)};${escape(region)};${escape(postcode)};${escape(country)}`, - )); + const typeParts: string[] = []; + const ctxType = contextToType(phone.contexts); + if (ctxType) typeParts.push(ctxType); + if (phone.features) { + for (const feat of Object.keys(phone.features)) { + if (phone.features[feat]) typeParts.push(feat.toUpperCase()); + } + } + const params: string[] = []; + if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(',')}`); + if (phone.pref) params.push(`PREF=${phone.pref}`); + const paramStr = params.length > 0 ? `;${params.join(';')}` : ''; + lines.push(`TEL${paramStr}:${phone.number}`); } } if (contact.organizations) { for (const org of Object.values(contact.organizations)) { - if (!org.name) continue; - const units = org.units?.map((u) => u.name).filter(Boolean).join(';') || ''; - lines.push(fold(`ORG:${escape(org.name)}${units ? `;${escape(units)}` : ''}`)); + if (!org.name && !(org.units && org.units.length)) continue; + const parts = [org.name || '']; + if (org.units) parts.push(...org.units.map((u) => u.name)); + lines.push(`ORG:${parts.map(encodeValue).join(';')}`); } } if (contact.titles) { for (const title of Object.values(contact.titles)) { if (!title.name) continue; - lines.push(fold(`TITLE:${escape(title.name)}`)); + if (title.kind === 'role') { + lines.push(`ROLE:${encodeValue(title.name)}`); + } else { + lines.push(`TITLE:${encodeValue(title.name)}`); + } } } - const birthday = findBirthday(contact); - if (birthday) { - const bday = anniversaryDateToVCardDate(birthday.date); - if (bday) lines.push(fold(`BDAY:${bday}`)); + if (contact.addresses) { + for (const addr of Object.values(contact.addresses)) { + const type = contextToType(addr.contexts); + const adrParams: string[] = []; + if (type) adrParams.push(`TYPE=${type}`); + if (addr.countryCode) adrParams.push(`CC=${addr.countryCode}`); + if (addr.pref) adrParams.push(`PREF=${addr.pref}`); + const paramStr = adrParams.length > 0 ? `;${adrParams.join(';')}` : ''; + let street = addr.street || ''; + let locality = addr.locality || ''; + let region = addr.region || ''; + let postcode = addr.postcode || ''; + let country = addr.country || ''; + // RFC 9553 components-based address: extract flat fields for vCard ADR + if (addr.components && addr.components.length > 0) { + const findComp = (kind: string) => addr.components!.filter((c) => c.kind === kind).map((c) => c.value).join(' '); + const number = findComp('number'); + const name = findComp('name'); + street = street || [number, name].filter(Boolean).join(' '); + locality = locality || findComp('locality'); + region = region || findComp('region'); + postcode = postcode || findComp('postcode'); + country = country || findComp('country'); + } + const parts = ['', '', street, locality, region, postcode, country]; + lines.push(`ADR${paramStr}:${parts.map(encodeValue).join(';')}`); + } + } + + if (contact.anniversaries) { + for (const ann of Object.values(contact.anniversaries)) { + const dateStr = anniversaryDateToVcardString(ann.date); + if (ann.kind === 'birth') { + if (dateStr) lines.push(`BDAY:${dateStr}`); + if (ann.place?.fullAddress) { + lines.push(`BIRTHPLACE:${encodeValue(ann.place.fullAddress)}`); + } + } else if (ann.kind === 'wedding') { + if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`); + } else if (ann.kind === 'death') { + if (dateStr) lines.push(`DEATHDATE:${dateStr}`); + if (ann.place?.fullAddress) { + lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`); + } + } + } } if (contact.onlineServices) { for (const svc of Object.values(contact.onlineServices)) { if (!svc.uri) continue; - lines.push(fold(`URL:${escape(svc.uri)}`)); + if (svc.service || svc.user) { + // Output as IMPP for instant messaging / social profiles + const params: string[] = []; + if (svc.service) params.push(`X-SERVICE-TYPE=${svc.service}`); + const ctxType = contextToType(svc.contexts); + if (ctxType) params.push(`TYPE=${ctxType}`); + if (svc.pref) params.push(`PREF=${svc.pref}`); + const paramStr = params.length > 0 ? `;${params.join(';')}` : ''; + lines.push(`IMPP${paramStr}:${svc.uri}`); + } else { + // Output as URL for plain web links + const type = contextToType(svc.contexts); + const params: string[] = []; + if (type) params.push(`TYPE=${type}`); + if (svc.pref) params.push(`PREF=${svc.pref}`); + const paramStr = params.length > 0 ? `;${params.join(';')}` : ''; + lines.push(`URL${paramStr}:${svc.uri}`); + } + } + } + + if (contact.keywords) { + const cats = Object.keys(contact.keywords).filter((k) => contact.keywords![k]); + if (cats.length > 0) { + lines.push(`CATEGORIES:${cats.map(encodeValue).join(',')}`); + } + } + + if (contact.preferredLanguages) { + for (const lang of Object.values(contact.preferredLanguages)) { + const type = contextToType(lang.contexts); + const params: string[] = []; + if (type) params.push(`TYPE=${type}`); + if (lang.pref) params.push(`PREF=${lang.pref}`); + const paramStr = params.length > 0 ? `;${params.join(';')}` : ''; + lines.push(`LANG${paramStr}:${lang.language}`); + } + } + + if (contact.relatedTo) { + for (const [uri, rel] of Object.entries(contact.relatedTo)) { + const relType = rel.relation ? Object.keys(rel.relation).find((k) => rel.relation![k]) : undefined; + const typeParam = relType ? `;TYPE=${relType}` : ''; + lines.push(`RELATED${typeParam}:${uri}`); + } + } + + if (contact.cryptoKeys) { + for (const key of Object.values(contact.cryptoKeys)) { + const type = contextToType(key.contexts); + const params: string[] = []; + if (type) params.push(`TYPE=${type}`); + if (key.mediaType) params.push(`MEDIATYPE=${key.mediaType}`); + const paramStr = params.length > 0 ? `;${params.join(';')}` : ''; + lines.push(`KEY${paramStr}:${key.uri}`); + } + } + + if (contact.personalInfo) { + // RFC 6715 - emit EXPERTISE / HOBBY / INTEREST with LEVEL. + const levelOut: Record> = { + expertise: { high: 'expert', medium: 'average', low: 'beginner' }, + hobby: { high: 'high', medium: 'medium', low: 'low' }, + interest: { high: 'high', medium: 'medium', low: 'low' }, + }; + for (const info of Object.values(contact.personalInfo)) { + const propMap: Record = { + expertise: 'EXPERTISE', hobby: 'HOBBY', interest: 'INTEREST', + }; + const prop = propMap[info.kind]; + if (!prop) continue; + const levelParam = info.level && levelOut[info.kind]?.[info.level] + ? `;LEVEL=${levelOut[info.kind][info.level]}` : ''; + lines.push(`${prop}${levelParam}:${encodeValue(info.value)}`); + } + } + + if (contact.directories) { + for (const dir of Object.values(contact.directories)) { + const mt = dir.mediaType ? `;MEDIATYPE=${dir.mediaType}` : ''; + lines.push(`ORG-DIRECTORY${mt}:${dir.uri}`); + } + } + + if (contact.links) { + // RFC 8605 CONTACT-URI for kind=contact; everything else falls back to URL. + for (const link of Object.values(contact.links)) { + const params: string[] = []; + const type = contextToType(link.contexts); + if (type) params.push(`TYPE=${type}`); + if (link.pref) params.push(`PREF=${link.pref}`); + const paramStr = params.length > 0 ? `;${params.join(';')}` : ''; + const prop = link.kind === 'contact' ? 'CONTACT-URI' : 'URL'; + lines.push(`${prop}${paramStr}:${link.uri}`); } } if (contact.notes) { - for (const note of Object.values(contact.notes)) { - if (!note.note) continue; - lines.push(fold(`NOTE:${escape(note.note)}`)); + for (const n of Object.values(contact.notes)) { + if (!n.note) continue; + lines.push(`NOTE:${encodeValue(n.note)}`); } } - const keywords = getContactKeywords(contact); - if (keywords.length > 0) { - lines.push(fold(`CATEGORIES:${keywords.map(escape).join(',')}`)); + if (contact.members) { + for (const memberId of Object.keys(contact.members)) { + if (contact.members[memberId]) { + lines.push(`MEMBER:urn:uuid:${memberId}`); + } + } } - if (contact.uid) { - lines.push(fold(`UID:${escape(contact.uid)}`)); + if (contact.media) { + for (const media of Object.values(contact.media)) { + if (media.uri) { + const prop = media.kind === 'logo' ? 'LOGO' : media.kind === 'sound' ? 'SOUND' : 'PHOTO'; + if (media.uri.startsWith('data:')) { + const match = media.uri.match(/^data:([^;]+);base64,(.+)$/); + if (match) { + lines.push(`${prop};ENCODING=b;TYPE=${match[1]}:${match[2]}`); + } + } else { + const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : ''; + lines.push(`${prop};VALUE=URI${mt}:${media.uri}`); + } + } + } + } + + // GEO and TZ from addresses + if (contact.addresses) { + for (const addr of Object.values(contact.addresses)) { + if (addr.coordinates) { + lines.push(`GEO:${addr.coordinates}`); + } + if (addr.timeZone) { + lines.push(`TZ:${addr.timeZone}`); + } + } + } + + if (contact.speakToAs) { + const sex = contact.speakToAs.grammaticalGender + ? grammaticalGenderToVcardSex(contact.speakToAs.grammaticalGender) + : ''; + const pronouns = contact.speakToAs.pronouns; + const identity = pronouns ? Object.values(pronouns)[0]?.pronouns || '' : ''; + if (sex || identity) { + lines.push(`GENDER:${sex}${identity ? `;${identity}` : ''}`); + } + } + + if (contact.calendarUri) { + lines.push(`CALURI:${contact.calendarUri}`); + } + + if (contact.schedulingUri) { + lines.push(`CALADRURI:${contact.schedulingUri}`); + } + + if (contact.freeBusyUri) { + lines.push(`FBURL:${contact.freeBusyUri}`); + } + + if (contact.source) { + lines.push(`SOURCE:${contact.source}`); + } + + if (contact.created) { + // RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip. + lines.push(`CREATED:${contact.created}`); } lines.push('END:VCARD'); return lines.join('\r\n'); } +export function detectDuplicates( + existing: ContactCard[], + incoming: ContactCard[], +): Map { + const dupes = new Map(); + const existingEmails = new Map(); + + for (const c of existing) { + if (c.emails) { + for (const e of Object.values(c.emails)) { + if (e.address) existingEmails.set(e.address.toLowerCase(), c.id); + } + } + } + + incoming.forEach((card, idx) => { + if (card.emails) { + for (const e of Object.values(card.emails)) { + if (!e.address) continue; + const match = existingEmails.get(e.address.toLowerCase()); + if (match) { + dupes.set(idx, match); + return; + } + } + } + }); + + return dupes; +} + +// ─── Backward-compatible single/multi export helpers ───────────── +// Older call sites (ContactDetailScreen, ContactsSettings) import these names. +export function contactToVCard(contact: ContactCard): string { + return generateVCard([contact]); +} + export function contactsToVCard(contacts: ContactCard[]): string { - return contacts.map(contactToVCard).join('\r\n'); + return generateVCard(contacts); } diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 8a99fd3e..ba70d42d 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -23,6 +23,8 @@ export type RootStackParamList = { ContactForm: { contactId?: string; addressBookId?: string; asGroup?: boolean }; GroupDetail: { groupId: string }; AddAccount: undefined; + Scheduled: undefined; + UnifiedInbox: undefined; }; export type MainTabsParamList = { diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 4f690166..a66a7b28 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -16,6 +16,7 @@ import { CalendarDays, LayoutGrid, List as ListIcon, + ListChecks, Menu, } from 'lucide-react-native'; import { @@ -47,11 +48,17 @@ import { type RecurrenceEditScope, } from '../components/calendar/RecurrenceScopeDialog'; import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer'; +import { TasksSheet } from '../components/calendar/TasksSheet'; +import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; +import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; import { buildEventDayIndex, eventsOnDayFromIndex, type EventDayIndex, } from '../lib/calendar-utils'; +import { buildReplyTo } from '../lib/calendar-invitation'; +import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; +import { useContactsStore } from '../stores/contacts-store'; import type { Calendar, CalendarEvent } from '../api/types'; type ViewMode = 'month' | 'week' | 'agenda'; @@ -108,6 +115,9 @@ export default function CalendarScreen() { const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek); const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat); + const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar); + const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); + const contacts = useContactsStore((s) => s.contacts); const [currentDate, setCurrentDate] = React.useState(new Date()); const [selectedDate, setSelectedDate] = React.useState(new Date()); @@ -125,6 +135,9 @@ export default function CalendarScreen() { const [modalVisible, setModalVisible] = React.useState(false); const [pendingAction, setPendingAction] = React.useState(null); const [sidebarVisible, setSidebarVisible] = React.useState(false); + const [tasksVisible, setTasksVisible] = React.useState(false); + const [importVisible, setImportVisible] = React.useState(false); + const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false); const [refreshing, setRefreshing] = React.useState(false); const hydrate = useCalendarStore((s) => s.hydrate); @@ -134,12 +147,36 @@ export default function CalendarScreen() { const createEvent = useCalendarStore((s) => s.createEvent); const updateEvent = useCalendarStore((s) => s.updateEvent); const deleteEvent = useCalendarStore((s) => s.deleteEvent); + const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); + const importEvents = useCalendarStore((s) => s.importEvents); + const tasks = useCalendarStore((s) => s.tasks); + const createTask = useCalendarStore((s) => s.createTask); + const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete); + const deleteTask = useCalendarStore((s) => s.deleteTask); const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility); - const allCalendars = useCalendarStore((s) => s.calendars); + const storeCalendars = useCalendarStore((s) => s.calendars); const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds); const loading = useCalendarStore((s) => s.loading); const error = useCalendarStore((s) => s.error); - const allEvents = useCalendarStore((s) => s.events); + const storeEvents = useCalendarStore((s) => s.events); + + // The birthday calendar is a client-side virtual calendar: its events are + // generated from contacts for the visible range and merged in alongside the + // server calendars. Toggling it on/off is instant (no refetch). + const birthdayEvents = React.useMemo(() => { + if (!showBirthdayCalendar) return []; + const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek); + return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString()); + }, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]); + + const allCalendars = React.useMemo( + () => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars), + [storeCalendars, showBirthdayCalendar], + ); + const allEvents = React.useMemo( + () => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents), + [storeEvents, birthdayEvents], + ); const calendars = React.useMemo( () => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)), @@ -210,23 +247,30 @@ export default function CalendarScreen() { setModalVisible(true); }, []); + const isReadOnlyEvent = React.useCallback( + (event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID], + [], + ); + const handleEditFromDetail = React.useCallback((event: CalendarEvent) => { + if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } setDetailEvent(null); if (event.recurrenceRules?.length || event.originalId) { setPendingAction({ kind: 'edit', event }); } else { openEditDirect(event); } - }, [openEditDirect]); + }, [openEditDirect, isReadOnlyEvent]); const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => { + if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } setDetailEvent(null); if (event.recurrenceRules?.length || event.originalId) { setPendingAction({ kind: 'delete', event }); } else { void deleteEvent(event.id); } - }, [deleteEvent]); + }, [deleteEvent, isReadOnlyEvent]); const handleScopeSelect = React.useCallback( async (scope: RecurrenceEditScope) => { @@ -293,6 +337,15 @@ export default function CalendarScreen() { + {enableCalendarTasks && ( + setTasksVisible(true)} + hitSlop={6} + > + + + )} {(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => { const Icon = @@ -397,6 +450,20 @@ export default function CalendarScreen() { onClose={() => setDetailEvent(null)} onEdit={handleEditFromDetail} onDelete={handleDeleteFromDetail} + onRsvp={async (ev, participantId, status) => { + await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev)); + setDetailEvent((cur) => + cur && cur.id === ev.id && cur.participants?.[participantId] + ? { + ...cur, + participants: { + ...cur.participants, + [participantId]: { ...cur.participants[participantId], participationStatus: status }, + }, + } + : cur, + ); + }} /> setSidebarVisible(false)} + onImport={() => { setSidebarVisible(false); setImportVisible(true); }} + onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }} + /> + + setTasksVisible(false)} + onCreate={createTask} + onToggle={toggleTaskComplete} + onDelete={deleteTask} + /> + + setImportVisible(false)} + onImport={importEvents} + /> + + setSubscriptionsVisible(false)} /> ); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 748e923f..9b4d3c43 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -9,8 +9,9 @@ import { X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough, List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, - Undo2, Redo2, FileText, + Undo2, Redo2, FileText, Clock, Check, } from 'lucide-react-native'; +import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import { File as FsFile } from 'expo-file-system'; @@ -33,6 +34,7 @@ import { } from '../lib/contact-utils'; import { getIdentities } from '../api/identity'; import { sendEmail, type OutgoingAttachment } from '../api/email'; +import { jmapClient } from '../api/jmap-client'; import { uploadBlob } from '../api/blob'; import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html'; import { stripDangerousTags } from '../lib/email-html'; @@ -256,11 +258,17 @@ export default function ComposeScreen({ route, navigation }: Props) { const [identityError, setIdentityError] = React.useState(null); const [sending, setSending] = React.useState(false); const [selectedIdentityId, setSelectedIdentityId] = React.useState(null); + const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false); + // Custom date/time picker stage. iOS shows one 'datetime' spinner; Android + // can only show one field at a time, so we walk date → time. + const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null); + const customDraftRef = React.useRef(new Date()); const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity); const plainTextMode = useSettingsStore((s) => s.plainTextMode); const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords); + const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds); const initialTo = React.useMemo(() => { if (!replyTo) { @@ -737,7 +745,96 @@ export default function ComposeScreen({ route, navigation }: Props) { }); }; - const onSend = async () => { + // Translate an absolute "send at" time into the HOLDFOR seconds the server + // expects, clamping to what it actually supports. Returns null (and alerts) + // when scheduling isn't possible so the caller can abort. + const resolveHoldForScheduledAt = (date: Date): number | null => { + const seconds = Math.ceil((date.getTime() - Date.now()) / 1000); + if (seconds <= 0) { + Alert.alert( + t('email_composer.schedule_past_title', 'Pick a future time'), + t('email_composer.schedule_past_body', 'The scheduled time must be in the future.'), + ); + return null; + } + if (!jmapClient.hasDelayedSend()) { + Alert.alert( + t('email_composer.schedule_unsupported_title', 'Scheduling unavailable'), + t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.'), + ); + return null; + } + const max = jmapClient.getMaxDelayedSend(); + if (max > 0 && seconds > max) { + Alert.alert( + t('email_composer.schedule_too_late_title', 'Too far ahead'), + t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'), + ); + return null; + } + return seconds; + }; + + // The Send button: applies the global undo-send delay when the server + // supports it, otherwise sends immediately. + const onSend = () => { + const holdFor = sendDelaySeconds > 0 && jmapClient.hasDelayedSend() ? sendDelaySeconds : undefined; + void performSend(holdFor); + }; + + const onScheduleConfirm = (date: Date) => { + const holdFor = resolveHoldForScheduledAt(date); + if (holdFor == null) return; + setScheduleSheetOpen(false); + void performSend(holdFor, date); + }; + + const schedulePresets = React.useMemo(() => { + const now = new Date(); + const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000); + const tomorrowMorning = new Date(now); + tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); + tomorrowMorning.setHours(8, 0, 0, 0); + return [ + { label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, + { label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) }, + { label: t('email_composer.schedule_tomorrow', 'Tomorrow, 8:00 AM'), date: tomorrowMorning }, + ]; + }, [t, scheduleSheetOpen]); + + const startCustomPicker = () => { + customDraftRef.current = new Date(Date.now() + 3600 * 1000); + setScheduleSheetOpen(false); + setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date'); + }; + + const onCustomPickerChange = (event: DateTimePickerEvent, selected?: Date) => { + if (event.type === 'dismissed' || !selected) { + setCustomStage(null); + return; + } + if (Platform.OS === 'ios') { + // Single spinner — keep it open, just remember the latest value. + customDraftRef.current = selected; + return; + } + // Android: combine the date step with the existing time, then ask for time. + if (customStage === 'date') { + const d = new Date(customDraftRef.current); + d.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate()); + customDraftRef.current = d; + setCustomStage('time'); + return; + } + if (customStage === 'time') { + const d = new Date(customDraftRef.current); + d.setHours(selected.getHours(), selected.getMinutes(), 0, 0); + setCustomStage(null); + onScheduleConfirm(d); + } + }; + + const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => { if (!canSend || !primaryIdentity || !sentMailbox) return; if (!(await passesAttachmentReminder())) return; setSending(true); @@ -775,7 +872,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const outgoing = [...inlineFromBody, ...fileAttachments]; - await sendEmail( + const result = await sendEmail( { from, to: finalTo.map((r) => ({ name: r.name || undefined, email: r.email })), @@ -793,7 +890,21 @@ export default function ComposeScreen({ route, navigation }: Props) { }, primaryIdentity.id, sentMailbox.id, + holdForSeconds, ); + // Confirm an explicit "send later" so the user knows it didn't go out + // now. The brief undo-send delay stays silent — it's meant to be + // invisible unless the user cancels from the Scheduled view. + if (scheduledAt && result.scheduled) { + const when = result.sendAt ? new Date(result.sendAt) : scheduledAt; + Alert.alert( + t('email_composer.scheduled_title', 'Scheduled'), + t('email_composer.scheduled_body', 'Your message will be sent at {time}.').replace( + '{time}', + when.toLocaleString(), + ), + ); + } navigation.goBack(); } catch (e) { Alert.alert( @@ -828,10 +939,18 @@ export default function ComposeScreen({ route, navigation }: Props) { > + setScheduleSheetOpen(true)} + style={styles.headerBtn} + hitSlop={8} + disabled={!canSend} + > + +