From d616943c2565f23c30810c5ebe237e43c72ddbb1 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 29 May 2026 15:34:39 +0200
Subject: [PATCH] feat: add unified inbox, scheduled send, iCal
import/subscriptions, tasks, and contact tagging/import
---
App.tsx | 4 +
src/api/__tests__/contacts.test.ts | 57 +
src/api/calendar.ts | 148 +-
src/api/contacts.ts | 55 +
src/api/email.ts | 213 ++-
src/api/jmap-client.ts | 53 +
src/api/types.ts | 71 +-
src/api/unified-inbox.ts | 196 +++
src/components/EmailBodyView.tsx | 16 +
src/components/SidebarDrawer.tsx | 30 +
src/components/TagSheet.tsx | 142 ++
.../calendar/CalendarSidebarDrawer.tsx | 45 +-
src/components/calendar/EventDetailSheet.tsx | 164 +-
src/components/calendar/EventModal.tsx | 217 ++-
src/components/calendar/ICalImportSheet.tsx | 391 +++++
.../calendar/ICalSubscriptionSheet.tsx | 262 ++++
src/components/calendar/TasksSheet.tsx | 371 +++++
.../contacts/AddressBookPickerSheet.tsx | 218 +++
.../contacts/ContactImportSheet.tsx | 391 +++++
src/components/contacts/TagAssignSheet.tsx | 190 +++
src/components/contacts/index.ts | 3 +
.../email/CalendarInvitationBanner.tsx | 305 ++++
src/components/settings/ComposingSettings.tsx | 46 +
src/components/settings/ContactsSettings.tsx | 306 +++-
src/lib/__tests__/calendar-alerts.test.ts | 80 +
src/lib/__tests__/vcard.test.ts | 122 ++
src/lib/birthday-calendar.ts | 117 ++
src/lib/calendar-alerts.ts | 103 ++
src/lib/calendar-invitation.ts | 148 ++
src/lib/uuid.ts | 20 +
src/lib/vcard.ts | 1319 +++++++++++++++--
src/navigation/types.ts | 2 +
src/screens/CalendarScreen.tsx | 99 +-
src/screens/ComposeScreen.tsx | 243 ++-
src/screens/ContactDetailScreen.tsx | 26 +-
src/screens/ContactsScreen.tsx | 108 +-
src/screens/EmailListScreen.tsx | 76 +-
src/screens/EmailThreadScreen.tsx | 4 +
src/screens/ScheduledScreen.tsx | 196 +++
src/screens/UnifiedInboxScreen.tsx | 234 +++
src/stores/__tests__/calendar-store.test.ts | 4 +-
src/stores/calendar-store.ts | 128 +-
src/stores/calendar-subscriptions-store.ts | 168 +++
src/stores/contacts-store.ts | 157 ++
src/stores/email-store.ts | 135 ++
src/stores/settings-store.ts | 4 +
46 files changed, 7111 insertions(+), 276 deletions(-)
create mode 100644 src/api/unified-inbox.ts
create mode 100644 src/components/TagSheet.tsx
create mode 100644 src/components/calendar/ICalImportSheet.tsx
create mode 100644 src/components/calendar/ICalSubscriptionSheet.tsx
create mode 100644 src/components/calendar/TasksSheet.tsx
create mode 100644 src/components/contacts/AddressBookPickerSheet.tsx
create mode 100644 src/components/contacts/ContactImportSheet.tsx
create mode 100644 src/components/contacts/TagAssignSheet.tsx
create mode 100644 src/components/email/CalendarInvitationBanner.tsx
create mode 100644 src/lib/__tests__/calendar-alerts.test.ts
create mode 100644 src/lib/__tests__/vcard.test.ts
create mode 100644 src/lib/birthday-calendar.ts
create mode 100644 src/lib/calendar-alerts.ts
create mode 100644 src/lib/calendar-invitation.ts
create mode 100644 src/lib/uuid.ts
create mode 100644 src/screens/ScheduledScreen.tsx
create mode 100644 src/screens/UnifiedInboxScreen.tsx
create mode 100644 src/stores/calendar-subscriptions-store.ts
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
+ {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}
+ >
+
+
+
+ {/* Schedule send sheet */}
+ setScheduleSheetOpen(false)}
+ >
+ setScheduleSheetOpen(false)}>
+ {}}>
+
+ {t('email_composer.schedule_send', 'Schedule send')}
+
+ {!jmapClient.hasDelayedSend() && (
+
+ {t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.')}
+
+ )}
+ {schedulePresets.map((preset) => (
+ onScheduleConfirm(preset.date)}
+ >
+
+ {preset.label}
+
+ {preset.date.toLocaleString(undefined, {
+ weekday: 'short', hour: 'numeric', minute: '2-digit',
+ })}
+
+
+ ))}
+
+
+
+ {t('email_composer.schedule_custom', 'Pick date & time…')}
+
+
+ setScheduleSheetOpen(false)}
+ >
+ {t('email_composer.cancel', 'Cancel')}
+
+
+
+
+
+ {customStage !== null && Platform.OS === 'ios' && (
+ setCustomStage(null)}>
+ setCustomStage(null)}>
+ {}}>
+
+
+ setCustomStage(null)}>
+ {t('email_composer.cancel', 'Cancel')}
+
+ { setCustomStage(null); onScheduleConfirm(customDraftRef.current); }}
+ >
+
+ {t('email_composer.schedule_send', 'Schedule send')}
+
+
+
+
+
+
+ )}
+
+ {customStage !== null && Platform.OS !== 'ios' && (
+
+ )}
);
}
@@ -1302,5 +1508,34 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.primary,
},
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
+ scheduleCard: {
+ width: '100%',
+ maxWidth: 420,
+ backgroundColor: c.background,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ borderColor: c.border,
+ padding: spacing.lg,
+ gap: spacing.xs,
+ },
+ scheduleWarning: {
+ ...typography.caption,
+ color: c.error,
+ marginBottom: spacing.xs,
+ },
+ scheduleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.sm + 2,
+ minHeight: 44,
+ },
+ scheduleRowLabel: { ...typography.body, color: c.text, flex: 1 },
+ scheduleRowTime: { ...typography.caption, color: c.textMuted },
+ scheduleCancel: {
+ alignItems: 'center',
+ paddingVertical: spacing.sm,
+ marginTop: spacing.xs,
+ },
});
}
diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx
index 94979ec8..6c239b01 100644
--- a/src/screens/ContactDetailScreen.tsx
+++ b/src/screens/ContactDetailScreen.tsx
@@ -12,7 +12,7 @@ import {
ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages,
- Clock, KeyRound,
+ Clock, KeyRound, FolderInput,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -27,6 +27,7 @@ import { contactToVCard } from '../lib/vcard';
import SenderAvatar from '../components/SenderAvatar';
import Dialog from '../components/Dialog';
import { ContactActivity } from '../components/contacts/ContactActivity';
+import AddressBookPickerSheet from '../components/contacts/AddressBookPickerSheet';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -84,9 +85,11 @@ export default function ContactDetailScreen() {
const allContacts = useContactsStore((s) => s.contacts);
const deleteContact = useContactsStore((s) => s.deleteContact);
const createContact = useContactsStore((s) => s.createContact);
+ const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
const [confirmDelete, setConfirmDelete] = React.useState(false);
const [moreOpen, setMoreOpen] = React.useState(false);
+ const [moveOpen, setMoveOpen] = React.useState(false);
if (!contact) {
return (
@@ -232,12 +235,26 @@ export default function ContactDetailScreen() {
}
};
+ const doMove = async (bookId: string) => {
+ setMoveOpen(false);
+ try {
+ await moveContactsToAddressBook([contact.id], bookId);
+ } catch (err) {
+ Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
+ }
+ };
+
const moreItems = [
!isGroup(contact) && {
icon: ,
label: 'Duplicate',
onPress: () => { void doDuplicate(); },
},
+ addressBooks.length > 0 && {
+ icon: ,
+ label: 'Move to address book',
+ onPress: () => setMoveOpen(true),
+ },
{
icon: ,
label: 'Export vCard',
@@ -614,6 +631,13 @@ export default function ContactDetailScreen() {
items={moreItems}
onClose={() => setMoreOpen(false)}
/>
+
+ setMoveOpen(false)}
+ currentBookId={bookIds[0] ?? null}
+ onPick={(id) => { void doMove(id); }}
+ />
);
}
diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx
index d3891876..b63c9021 100644
--- a/src/screens/ContactsScreen.tsx
+++ b/src/screens/ContactsScreen.tsx
@@ -9,12 +9,13 @@ import {
ActivityIndicator,
RefreshControl,
ScrollView,
+ Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
- Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
+ Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -25,7 +26,12 @@ import {
type ContactCategory,
} from '../stores/contacts-store';
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
-import { ContactListRow } from '../components/contacts';
+import {
+ ContactListRow,
+ AddressBookPickerSheet,
+ ContactImportSheet,
+ TagAssignSheet,
+} from '../components/contacts';
import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { useSettingsStore } from '../stores/settings-store';
@@ -91,6 +97,8 @@ export default function ContactsScreen() {
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
+ const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
+ const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
@@ -99,6 +107,11 @@ export default function ContactsScreen() {
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [selection, setSelection] = React.useState>(new Set());
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
+ const [moveSheetOpen, setMoveSheetOpen] = React.useState(false);
+ const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
+ const [importOpen, setImportOpen] = React.useState(false);
+ const [importTargetOpen, setImportTargetOpen] = React.useState(false);
+ const [importTargetBookId, setImportTargetBookId] = React.useState(null);
React.useEffect(() => {
void hydrate();
@@ -183,6 +196,45 @@ export default function ContactsScreen() {
await bulkDelete(ids);
};
+ // Default target for moves/imports: the currently-viewed address book, else
+ // the first available book.
+ const defaultBookId = React.useMemo(() => {
+ if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
+ return addressBooks[0]?.id ?? null;
+ }, [selectedCategory, addressBooks]);
+
+ const handleBulkMove = async (bookId: string) => {
+ const ids = Array.from(selection);
+ setMoveSheetOpen(false);
+ clearSelection();
+ try {
+ await moveContactsToAddressBook(ids, bookId);
+ } catch (err) {
+ Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
+ }
+ };
+
+ const handleBulkTag = async (keyword: string) => {
+ const ids = Array.from(selection);
+ setTagSheetOpen(false);
+ clearSelection();
+ try {
+ await addKeywordToContacts(ids, keyword);
+ } catch (err) {
+ Alert.alert('Tagging failed', err instanceof Error ? err.message : 'Unknown error');
+ }
+ };
+
+ const openImport = () => {
+ setImportTargetBookId(defaultBookId);
+ setImportOpen(true);
+ };
+
+ const importTargetName = React.useMemo(
+ () => addressBooks.find((b) => b.id === importTargetBookId)?.name,
+ [addressBooks, importTargetBookId],
+ );
+
const title = categoryTitle(selectedCategory, addressBooks, groups);
// Top chip row: All / per-address-book / Groups
@@ -234,6 +286,22 @@ export default function ContactsScreen() {
{selection.size} selected
+ {addressBooks.length > 0 && (
+ setMoveSheetOpen(true)}
+ style={styles.headerIconBtn}
+ hitSlop={8}
+ >
+
+
+ )}
+ setTagSheetOpen(true)}
+ style={styles.headerIconBtn}
+ hitSlop={8}
+ >
+
+
setConfirmBulkDelete(true)}
style={styles.headerIconBtn}
@@ -258,6 +326,13 @@ export default function ContactsScreen() {
>
+
+
+
navigation.navigate('ContactForm', {})}
style={styles.addBtn}
@@ -380,6 +455,35 @@ export default function ContactsScreen() {
onConfirm={doBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
+
+ setMoveSheetOpen(false)}
+ onPick={(id) => { void handleBulkMove(id); }}
+ />
+
+ setTagSheetOpen(false)}
+ onPick={(kw) => { void handleBulkTag(kw); }}
+ />
+
+ setImportOpen(false)}
+ targetBookId={importTargetBookId}
+ targetBookName={importTargetName}
+ onChangeTarget={addressBooks.length > 1 ? () => setImportTargetOpen(true) : undefined}
+ onImported={() => { void fetchContacts(); }}
+ />
+
+ setImportTargetOpen(false)}
+ currentBookId={importTargetBookId}
+ title="Import into address book"
+ onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
+ />
);
}
diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx
index 61b7b781..129c04fb 100644
--- a/src/screens/EmailListScreen.tsx
+++ b/src/screens/EmailListScreen.tsx
@@ -5,6 +5,7 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
+ Archive, FolderInput, Tag,
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -13,11 +14,13 @@ import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { MoveSheet } from '../components/MoveSheet';
+import { TagSheet } from '../components/TagSheet';
import { UndoSnackbar } from '../components/UndoSnackbar';
import { OfflineBanner } from '../components/OfflineBanner';
import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
+import { useKeywordsStore } from '../stores/keywords-store';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
@@ -191,6 +194,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
+ const archiveEmailsBatch = useEmailStore((s) => s.archiveEmailsBatch);
+ const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
+ const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
+ const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
+
+ const keywordDefs = useKeywordsStore((s) => s.keywords);
+ const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
+ const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
+ React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
@@ -240,6 +252,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
// Move-to-folder picker triggered by the swipe action.
const [pendingMoveId, setPendingMoveId] = React.useState(null);
+ // Batch (multi-select) sheets.
+ const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
+ const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
// Selection state
const [selectedIds, setSelectedIds] = React.useState>(new Set());
@@ -385,11 +400,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
clearSelection();
return;
}
- const ids = Array.from(selectedIds);
- await Promise.all(ids.map((id) => deleteEmailAction(id, trash.id, currentMailboxId)));
+ await deleteEmailsBatch(Array.from(selectedIds), trash.id, currentMailboxId);
clearSelection();
};
+ const handleBulkArchive = async () => {
+ await archiveEmailsBatch(Array.from(selectedIds));
+ clearSelection();
+ };
+
+ const canArchiveSelection =
+ archiveMailboxId != null && currentMailboxId !== archiveMailboxId;
+
+ const handleBatchMovePick = (toId: string) => {
+ const ids = Array.from(selectedIds);
+ setBatchMoveOpen(false);
+ clearSelection();
+ void moveEmailsToMailbox(ids, toId);
+ };
+
+ const handleBatchTagToggle = (token: string, on: boolean) => {
+ void setKeywordForEmails(Array.from(selectedIds), token, on);
+ };
+
// Local input state for uninterrupted typing; debounce into the store.
const [searchInput, setSearchInput] = React.useState(storeSearchQuery);
React.useEffect(() => {
@@ -486,6 +519,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
)}
+ setTagSheetOpen(true)}
+ style={styles.headerButton}
+ hitSlop={6}
+ >
+
+
+ setBatchMoveOpen(true)}
+ style={styles.headerButton}
+ hitSlop={6}
+ >
+
+
+ {canArchiveSelection && (
+ { void handleBulkArchive(); }}
+ style={styles.headerButton}
+ hitSlop={6}
+ >
+
+
+ )}
{ void handleBulkDelete(); }}
style={styles.headerButton}
@@ -891,6 +947,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}}
/>
+ setBatchMoveOpen(false)}
+ mailboxes={mailboxes}
+ currentMailboxId={currentMailboxId}
+ onPick={handleBatchMovePick}
+ />
+
+ setTagSheetOpen(false)}
+ keywords={keywordDefs}
+ selectedEmails={selectedEmails}
+ onToggle={handleBatchTagToggle}
+ />
+
);
diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx
index bd228e8a..8e84d8c4 100644
--- a/src/screens/EmailThreadScreen.tsx
+++ b/src/screens/EmailThreadScreen.tsx
@@ -14,6 +14,7 @@ import {
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import EmailBodyView from '../components/EmailBodyView';
+import { CalendarInvitationBanner } from '../components/email/CalendarInvitationBanner';
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
@@ -434,6 +435,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
+ {/* Calendar invitation (auto-detected .ics) */}
+
+
{/* Body */}
diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx
new file mode 100644
index 00000000..49584e89
--- /dev/null
+++ b/src/screens/ScheduledScreen.tsx
@@ -0,0 +1,196 @@
+import React from 'react';
+import {
+ View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ArrowLeft, Clock, X } from 'lucide-react-native';
+import type { NativeStackScreenProps } from '@react-navigation/native-stack';
+import type { RootStackParamList } from '../navigation/types';
+import {
+ listScheduledEmails,
+ cancelScheduledSend,
+ type ScheduledEmail,
+} from '../api/email';
+import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
+import { useColors } from '../theme/colors';
+
+type Props = NativeStackScreenProps;
+
+function recipientLabel(item: ScheduledEmail): string {
+ const first = item.to?.[0];
+ if (!first) return '(no recipient)';
+ const name = first.name || first.email;
+ const extra = (item.to?.length ?? 0) - 1;
+ return extra > 0 ? `${name} +${extra}` : name;
+}
+
+export default function ScheduledScreen({ navigation }: Props) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ const [items, setItems] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+ const [cancelling, setCancelling] = React.useState(null);
+
+ const load = React.useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ setItems(await listScheduledEmails());
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to load scheduled messages');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ void load();
+ }, [load]);
+
+ const onCancel = (item: ScheduledEmail) => {
+ Alert.alert(
+ 'Cancel scheduled send?',
+ 'The message will not be delivered. A copy stays in your Sent folder.',
+ [
+ { text: 'Keep scheduled', style: 'cancel' },
+ {
+ text: 'Cancel send',
+ style: 'destructive',
+ onPress: () => {
+ setCancelling(item.emailSubmissionId);
+ void (async () => {
+ try {
+ await cancelScheduledSend(item.emailSubmissionId);
+ setItems((prev) =>
+ prev.filter((i) => i.emailSubmissionId !== item.emailSubmissionId),
+ );
+ } catch (e) {
+ Alert.alert('Cancel failed', e instanceof Error ? e.message : String(e));
+ } finally {
+ setCancelling(null);
+ }
+ })();
+ },
+ },
+ ],
+ );
+ };
+
+ const renderItem = ({ item }: { item: ScheduledEmail }) => (
+
+
+
+
+ {new Date(item.sendAt).toLocaleString()}
+
+
+ {item.subject || '(no subject)'}
+
+
+ To: {recipientLabel(item)}
+
+ {item.preview ? (
+ {item.preview}
+ ) : null}
+
+ onCancel(item)}
+ style={styles.cancelBtn}
+ hitSlop={8}
+ disabled={cancelling === item.emailSubmissionId}
+ >
+ {cancelling === item.emailSubmissionId ? (
+
+ ) : (
+
+ )}
+
+
+ );
+
+ return (
+
+
+ navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
+
+
+ Scheduled
+
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+ {error}
+ { void load(); }}>
+ Retry
+
+
+ ) : items.length === 0 ? (
+
+
+ No scheduled messages
+
+ Use the clock icon in the composer to send a message later.
+
+
+ ) : (
+ i.emailSubmissionId}
+ renderItem={renderItem}
+ ItemSeparatorComponent={() => }
+ contentContainerStyle={styles.listContent}
+ refreshing={loading}
+ onRefresh={() => { void load(); }}
+ />
+ )}
+
+ );
+}
+
+function makeStyles(c: ThemePalette) {
+ return StyleSheet.create({
+ container: { flex: 1, backgroundColor: c.background },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ height: componentSizes.headerHeight,
+ paddingHorizontal: spacing.lg,
+ borderBottomWidth: 1,
+ borderBottomColor: c.border,
+ gap: spacing.sm,
+ },
+ headerBtn: {
+ width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
+ },
+ headerTitle: { ...typography.h3, color: c.text, flex: 1 },
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
+ error: { ...typography.body, color: c.error, textAlign: 'center' },
+ retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
+ emptyText: { ...typography.body, color: c.textSecondary },
+ emptyHint: { ...typography.caption, color: c.textMuted, textAlign: 'center', maxWidth: 280 },
+ listContent: { paddingVertical: spacing.sm },
+ separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.md,
+ },
+ rowMain: { flex: 1, minWidth: 0, gap: 2 },
+ sendAtRow: { flexDirection: 'row', alignItems: 'center', gap: 5 },
+ sendAt: { ...typography.caption, color: c.primary, fontWeight: '600' },
+ subject: { ...typography.bodyMedium, color: c.text },
+ recipient: { ...typography.caption, color: c.textSecondary },
+ preview: { ...typography.caption, color: c.textMuted },
+ cancelBtn: {
+ width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm,
+ },
+ });
+}
diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx
new file mode 100644
index 00000000..f452e4fa
--- /dev/null
+++ b/src/screens/UnifiedInboxScreen.tsx
@@ -0,0 +1,234 @@
+import React from 'react';
+import {
+ View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ArrowLeft, Star, Paperclip, AlertTriangle } from 'lucide-react-native';
+import type { NativeStackScreenProps } from '@react-navigation/native-stack';
+import type { RootStackParamList } from '../navigation/types';
+import SenderAvatar from '../components/SenderAvatar';
+import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
+import { useAccountStore } from '../stores/account-store';
+import { useAuthStore } from '../stores/auth-store';
+import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
+import { useColors } from '../theme/colors';
+
+type Props = NativeStackScreenProps;
+
+function senderName(email: UnifiedEmail): string {
+ return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
+}
+
+function formatRelativeTime(date: Date): string {
+ const diffMin = Math.floor((Date.now() - date.getTime()) / 60000);
+ if (diffMin < 1) return 'now';
+ if (diffMin < 60) return `${diffMin}m`;
+ const diffHr = Math.floor(diffMin / 60);
+ if (diffHr < 24) return `${diffHr}h`;
+ const diffDay = Math.floor(diffHr / 24);
+ if (diffDay < 7) return `${diffDay}d`;
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+
+export default function UnifiedInboxScreen({ navigation }: Props) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ const accounts = useAccountStore((s) => s.accounts);
+ const switchAccount = useAuthStore((s) => s.switchAccount);
+
+ const [emails, setEmails] = React.useState([]);
+ const [errors, setErrors] = React.useState>({});
+ const [loading, setLoading] = React.useState(true);
+ const [opening, setOpening] = React.useState(false);
+
+ const accountById = React.useMemo(
+ () => new Map(accounts.map((a) => [a.id, a])),
+ [accounts],
+ );
+ const accountIds = React.useMemo(() => accounts.map((a) => a.id), [accounts]);
+
+ const load = React.useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await fetchUnifiedInbox(accountIds);
+ setEmails(result.emails);
+ setErrors(result.errors);
+ } finally {
+ setLoading(false);
+ }
+ }, [accountIds]);
+
+ React.useEffect(() => {
+ void load();
+ }, [load]);
+
+ const onOpen = React.useCallback(
+ (email: UnifiedEmail) => {
+ if (opening) return;
+ setOpening(true);
+ void (async () => {
+ try {
+ const auth = useAuthStore.getState();
+ if (auth.activeAccountId !== email.sourceAccountId) {
+ await switchAccount(email.sourceAccountId);
+ if (useAuthStore.getState().activeAccountId !== email.sourceAccountId) return;
+ }
+ navigation.navigate('EmailThread', {
+ emailId: email.id,
+ threadId: email.threadId,
+ subject: email.subject,
+ });
+ } finally {
+ setOpening(false);
+ }
+ })();
+ },
+ [opening, switchAccount, navigation],
+ );
+
+ const errorCount = Object.keys(errors).length;
+
+ const renderItem = ({ item }: { item: UnifiedEmail }) => {
+ const acc = accountById.get(item.sourceAccountId);
+ const unread = !item.keywords?.$seen;
+ const starred = !!item.keywords?.$flagged;
+ return (
+ onOpen(item)}
+ style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
+ >
+
+
+ {acc && }
+
+
+
+
+ {senderName(item)}
+
+ {starred && }
+ {item.hasAttachment && }
+ {formatRelativeTime(new Date(item.receivedAt))}
+
+
+ {item.subject || '(no subject)'}
+
+
+ {acc && (
+
+ {acc.email || acc.username}
+
+ )}
+
+ {item.preview ? (
+ {item.preview}
+ ) : null}
+
+
+ );
+ };
+
+ return (
+
+
+ navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
+
+
+ All inboxes
+
+ {opening ? : null}
+
+
+
+ {errorCount > 0 && (
+
+
+
+ {errorCount === 1
+ ? '1 account could not be loaded'
+ : `${errorCount} accounts could not be loaded`}
+
+
+ )}
+
+ {loading && emails.length === 0 ? (
+
+
+ Loading all inboxes…
+
+ ) : emails.length === 0 ? (
+
+ No mail across your accounts
+
+ ) : (
+ `${item.sourceAccountId}:${item.id}`}
+ renderItem={renderItem}
+ ItemSeparatorComponent={() => }
+ refreshing={loading}
+ onRefresh={() => { void load(); }}
+ />
+ )}
+
+ );
+}
+
+function makeStyles(c: ThemePalette) {
+ return StyleSheet.create({
+ container: { flex: 1, backgroundColor: c.background },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ height: componentSizes.headerHeight,
+ paddingHorizontal: spacing.lg,
+ borderBottomWidth: 1,
+ borderBottomColor: c.border,
+ gap: spacing.sm,
+ },
+ headerBtn: {
+ width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
+ },
+ headerTitle: { ...typography.h3, color: c.text, flex: 1 },
+ errorBanner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ backgroundColor: c.errorBg,
+ },
+ errorBannerText: { ...typography.caption, color: c.error, flex: 1 },
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm },
+ loadingText: { ...typography.body, color: c.textSecondary },
+ separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
+ row: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.md,
+ },
+ rowPressed: { backgroundColor: c.surfaceHover },
+ avatarWrap: { position: 'relative' },
+ accountDot: {
+ position: 'absolute',
+ right: -2, bottom: -2,
+ width: 12, height: 12,
+ borderRadius: 6,
+ borderWidth: 2,
+ borderColor: c.background,
+ },
+ content: { flex: 1, minWidth: 0, gap: 1 },
+ line: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ sender: { ...typography.bodyMedium, color: c.text, flex: 1 },
+ bold: { fontWeight: '700' },
+ time: { ...typography.caption, color: c.textMuted },
+ subject: { ...typography.body, color: c.text },
+ account: { ...typography.caption, color: c.textMuted },
+ preview: { ...typography.caption, color: c.textMuted },
+ });
+}
diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts
index f24db0f1..8aa6f537 100644
--- a/src/stores/__tests__/calendar-store.test.ts
+++ b/src/stores/__tests__/calendar-store.test.ts
@@ -272,7 +272,9 @@ describe('calendar-store', () => {
{ title: 'New' } as any,
);
- expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' });
+ // The third arg is the iMIP scheduling flag — undefined here since the
+ // event has no participants to notify.
+ expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined);
});
});
diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts
index 2490cd8d..d7c5f727 100644
--- a/src/stores/calendar-store.ts
+++ b/src/stores/calendar-store.ts
@@ -9,10 +9,19 @@ import {
createEvent as apiCreateEvent,
updateEvent as apiUpdateEvent,
deleteEvents as apiDeleteEvents,
+ batchCreateEvents as apiBatchCreateEvents,
+ rsvpEvent as apiRsvpEvent,
+ createCalendar as apiCreateCalendar,
} from '../api/calendar';
import { jmapClient } from '../api/jmap-client';
import { expandRecurringEvents } from '../lib/recurrence-expansion';
+// Does the event carry attendees the server should notify over iMIP? Used to
+// decide whether to set sendSchedulingMessages on create/update/delete.
+function hasSchedulingParticipants(event?: Partial | null): boolean {
+ return !!event?.participants && Object.keys(event.participants).length > 0;
+}
+
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
export interface LoadedRange {
@@ -23,6 +32,7 @@ export interface LoadedRange {
export interface CalendarState {
calendars: Calendar[];
events: CalendarEvent[];
+ tasks: CalendarEvent[];
hiddenCalendarIds: string[];
loadedRange: LoadedRange | null;
loading: boolean;
@@ -38,6 +48,20 @@ export interface CalendarState {
createEvent: (event: Partial, calendarId: string) => Promise;
updateEvent: (id: string, changes: Partial) => Promise;
deleteEvent: (id: string) => Promise;
+ // Invitations / scheduling
+ rsvpEvent: (
+ eventId: string,
+ participantId: string,
+ status: 'accepted' | 'declined' | 'tentative',
+ replyTo?: Record | null,
+ ) => Promise;
+ importEvents: (events: Partial[], calendarId: string) => Promise;
+ createCalendar: (name: string, color?: string) => Promise;
+ // Tasks
+ createTask: (task: Partial, calendarId: string) => Promise;
+ updateTask: (id: string, changes: Partial) => Promise;
+ toggleTaskComplete: (id: string) => Promise;
+ deleteTask: (id: string) => Promise;
toggleCalendarVisibility: (id: string) => void;
setCalendarHidden: (id: string, hidden: boolean) => void;
reset: () => void;
@@ -66,6 +90,7 @@ export const useCalendarStore = create()(
(set, get) => ({
calendars: [],
events: [],
+ tasks: [],
hiddenCalendarIds: [],
loadedRange: null,
loading: false,
@@ -108,14 +133,16 @@ export const useCalendarStore = create()(
try {
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
const raw = ids.length > 0 ? ((await fetchEvents(ids)) ?? []) : [];
- // Stalwart returns both Events and Tasks from CalendarEvent/query; tasks
- // lack `start` and must be filtered out client-side.
+ // Stalwart returns both Events and Tasks from CalendarEvent/query. Events
+ // have a `start`; tasks are surfaced separately so they don't pollute the
+ // grid (they're shown in the task list with their due date instead).
const onlyEvents = raw.filter((e) => {
const t = (e as { '@type'?: string })['@type'];
return (t === 'Event' || t === undefined) && !!e.start;
});
+ const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task');
const events = expandRecurringEvents(onlyEvents, after, before);
- set({ events, loadedRange: { after, before }, loading: false });
+ set({ events, tasks, loadedRange: { after, before }, loading: false });
} catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
}
@@ -166,7 +193,11 @@ export const useCalendarStore = create()(
},
createEvent: async (event, calendarId) => {
- const created = await apiCreateEvent(event, calendarId);
+ const created = await apiCreateEvent(
+ event,
+ calendarId,
+ hasSchedulingParticipants(event) ? true : undefined,
+ );
set({ events: [...get().events, created] });
return created;
},
@@ -175,7 +206,11 @@ export const useCalendarStore = create()(
// Resolve client-side expanded occurrence IDs back to the master event ID.
const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id;
- await apiUpdateEvent(realId, changes);
+ // Notify attendees if either the stored event or the incoming changes
+ // carry participants.
+ const schedule =
+ hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent);
+ await apiUpdateEvent(realId, changes, schedule ? true : undefined);
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)),
});
@@ -184,10 +219,89 @@ export const useCalendarStore = create()(
deleteEvent: async (id) => {
const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id;
- await apiDeleteEvents([realId]);
+ await apiDeleteEvents(
+ [realId],
+ hasSchedulingParticipants(storeEvent) ? true : undefined,
+ );
set({ events: get().events.filter((e) => e.id !== id) });
},
+ rsvpEvent: async (eventId, participantId, status, replyTo) => {
+ if (!participantId || participantId.includes('..')) {
+ throw new Error('Invalid participant ID');
+ }
+ const storeEvent = get().events.find((e) => e.id === eventId);
+ const realId = storeEvent?.originalId || eventId;
+ await apiRsvpEvent(realId, participantId, status, replyTo);
+ set({
+ events: get().events.map((e) => {
+ if (e.id !== eventId || !e.participants?.[participantId]) return e;
+ return {
+ ...e,
+ participants: {
+ ...e.participants,
+ [participantId]: { ...e.participants[participantId], participationStatus: status },
+ },
+ };
+ }),
+ });
+ },
+
+ importEvents: async (events, calendarId) => {
+ if (events.length === 0) return 0;
+ // Stalwart enforces UID uniqueness across calendars. Skip events whose UID
+ // already exists; create the rest. (We don't attempt cross-calendar linking
+ // on mobile — a duplicate is simply skipped.)
+ let toCreate = events;
+ try {
+ const existingIds = await queryEvents([], '', '');
+ const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
+ const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
+ toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
+ } catch {
+ // Couldn't dedupe — proceed and let the server reject genuine dupes.
+ }
+ if (toCreate.length === 0) return 0;
+ const count = await apiBatchCreateEvents(toCreate, calendarId);
+ await get().refresh();
+ return count;
+ },
+
+ createCalendar: async (name, color) => {
+ const created = await apiCreateCalendar(name, color);
+ set({ calendars: [...get().calendars, created] });
+ return created;
+ },
+
+ createTask: async (task, calendarId) => {
+ await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId);
+ await get().refresh();
+ },
+
+ updateTask: async (id, changes) => {
+ const task = get().tasks.find((t) => t.id === id);
+ const realId = task?.originalId || id;
+ await apiUpdateEvent(realId, changes);
+ set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
+ },
+
+ toggleTaskComplete: async (id) => {
+ const task = get().tasks.find((t) => t.id === id);
+ if (!task) return;
+ const completed = task.progress === 'completed';
+ const next: Partial = completed
+ ? { progress: 'in-process', percentComplete: 0 }
+ : { progress: 'completed', percentComplete: 100 };
+ await get().updateTask(id, next);
+ },
+
+ deleteTask: async (id) => {
+ const task = get().tasks.find((t) => t.id === id);
+ const realId = task?.originalId || id;
+ await apiDeleteEvents([realId]);
+ set({ tasks: get().tasks.filter((t) => t.id !== id) });
+ },
+
toggleCalendarVisibility: (id) => {
const { hiddenCalendarIds } = get();
const next = hiddenCalendarIds.includes(id)
@@ -211,6 +325,7 @@ export const useCalendarStore = create()(
reset: () => set({
calendars: [],
events: [],
+ tasks: [],
loadedRange: null,
loading: false,
error: null,
@@ -225,6 +340,7 @@ export const useCalendarStore = create()(
partialize: (state) => ({
calendars: state.calendars,
events: state.events,
+ tasks: state.tasks,
loadedRange: state.loadedRange,
}),
},
diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts
new file mode 100644
index 00000000..2481b1dc
--- /dev/null
+++ b/src/stores/calendar-subscriptions-store.ts
@@ -0,0 +1,168 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { CalendarEvent } from '../api/types';
+import {
+ createCalendar,
+ deleteCalendar,
+ parseCalendarBlob,
+ queryEvents,
+ getEvents,
+ deleteEvents,
+} from '../api/calendar';
+import { uploadBytes } from '../api/blob';
+import { useCalendarStore } from './calendar-store';
+
+export interface CalendarSubscription {
+ id: string;
+ name: string;
+ url: string;
+ color?: string;
+ /** The local Stalwart calendar that mirrors this remote feed. */
+ calendarId: string;
+ lastSyncAt: number | null;
+ lastError: string | null;
+}
+
+interface SubscriptionsState {
+ subscriptions: CalendarSubscription[];
+ syncing: Record;
+
+ addSubscription: (input: { name: string; url: string; color?: string }) => Promise;
+ removeSubscription: (id: string) => Promise;
+ syncSubscription: (id: string) => Promise;
+ syncAll: () => Promise;
+}
+
+// webcal:// is just iCalendar over HTTP(S) — browsers/clients map it to https.
+function normalizeFeedUrl(url: string): string {
+ return url.trim().replace(/^webcal:\/\//i, 'https://');
+}
+
+async function fetchAndParseFeed(url: string): Promise[]> {
+ const res = await fetch(normalizeFeedUrl(url));
+ if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`);
+ const text = await res.text();
+ if (!/BEGIN:VCALENDAR/i.test(text)) {
+ throw new Error('That URL did not return an iCalendar feed');
+ }
+ const bytes = new TextEncoder().encode(text);
+ const { blobId } = await uploadBytes(bytes, 'text/calendar');
+ const events = await parseCalendarBlob(blobId);
+ return events.filter((e) => !!e.start);
+}
+
+// Reconcile the local calendar with the remote feed: delete events whose UID
+// disappeared upstream, then import the current set (importEvents dedupes by
+// UID, so unchanged events are left in place).
+async function syncFeedIntoCalendar(calendarId: string, url: string): Promise {
+ const parsed = await fetchAndParseFeed(url);
+ const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
+
+ try {
+ const allIds = await queryEvents([], '', '');
+ const all = allIds.length > 0 ? await getEvents(allIds) : [];
+ const stale = all
+ .filter((e) => e.calendarIds?.[calendarId] && e.uid && !parsedUids.has(e.uid))
+ .map((e) => e.id);
+ if (stale.length > 0) await deleteEvents(stale);
+ } catch {
+ // Non-fatal: if we can't enumerate existing events, still import new ones.
+ }
+
+ await useCalendarStore.getState().importEvents(parsed, calendarId);
+}
+
+export const useCalendarSubscriptionsStore = create()(
+ persist(
+ (set, get) => ({
+ subscriptions: [],
+ syncing: {},
+
+ addSubscription: async ({ name, url, color }) => {
+ // Create a dedicated local calendar to hold the feed's events.
+ const calendar = await createCalendar(name, color);
+ const sub: CalendarSubscription = {
+ id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
+ name,
+ url: url.trim(),
+ color,
+ calendarId: calendar.id,
+ lastSyncAt: null,
+ lastError: null,
+ };
+ set({ subscriptions: [...get().subscriptions, sub] });
+ // Make the new calendar visible immediately.
+ await useCalendarStore.getState().fetchCalendars();
+ try {
+ set({ syncing: { ...get().syncing, [sub.id]: true } });
+ await syncFeedIntoCalendar(sub.calendarId, sub.url);
+ set({
+ subscriptions: get().subscriptions.map((s) =>
+ s.id === sub.id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
+ ),
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Sync failed';
+ set({
+ subscriptions: get().subscriptions.map((s) =>
+ s.id === sub.id ? { ...s, lastError: message } : s,
+ ),
+ });
+ } finally {
+ set({ syncing: { ...get().syncing, [sub.id]: false } });
+ }
+ return sub;
+ },
+
+ removeSubscription: async (id) => {
+ const sub = get().subscriptions.find((s) => s.id === id);
+ set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) });
+ if (sub) {
+ try {
+ await deleteCalendar(sub.calendarId);
+ } catch {
+ // best-effort
+ }
+ await useCalendarStore.getState().fetchCalendars();
+ await useCalendarStore.getState().refresh();
+ }
+ },
+
+ syncSubscription: async (id) => {
+ const sub = get().subscriptions.find((s) => s.id === id);
+ if (!sub) return;
+ set({ syncing: { ...get().syncing, [id]: true } });
+ try {
+ await syncFeedIntoCalendar(sub.calendarId, sub.url);
+ set({
+ subscriptions: get().subscriptions.map((s) =>
+ s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
+ ),
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Sync failed';
+ set({
+ subscriptions: get().subscriptions.map((s) =>
+ s.id === id ? { ...s, lastError: message } : s,
+ ),
+ });
+ } finally {
+ set({ syncing: { ...get().syncing, [id]: false } });
+ }
+ },
+
+ syncAll: async () => {
+ const subs = get().subscriptions;
+ for (const sub of subs) {
+ await get().syncSubscription(sub.id);
+ }
+ },
+ }),
+ {
+ name: 'calendar-subscriptions',
+ storage: createJSONStorage(() => AsyncStorage),
+ partialize: (state) => ({ subscriptions: state.subscriptions }),
+ },
+ ),
+);
diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts
index 47f72438..deffb134 100644
--- a/src/stores/contacts-store.ts
+++ b/src/stores/contacts-store.ts
@@ -9,6 +9,10 @@ import {
createContact as apiCreateContact,
updateContact as apiUpdateContact,
deleteContacts as apiDeleteContacts,
+ createAddressBook as apiCreateAddressBook,
+ updateAddressBook as apiUpdateAddressBook,
+ deleteAddressBook as apiDeleteAddressBook,
+ getContactsInBook,
} from '../api/contacts';
import { jmapClient } from '../api/jmap-client';
import {
@@ -20,6 +24,10 @@ import {
const SELECTED_CATEGORY_STORAGE_KEY = 'webmail:contacts:category:v1';
+// Dedicated JMAP address book that backs the "trusted senders" allow-list.
+// Mirrors the webmail so the same book is shared across clients.
+export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
+
export type ContactCategory =
| { type: 'all' }
| { type: 'addressBook'; addressBookId: string }
@@ -35,6 +43,13 @@ export interface ContactsState {
error: string | null;
hydrated: boolean;
+ // Trusted senders are stored as contacts in a dedicated JMAP address book so
+ // the allow-list syncs across devices (matches the webmail behavior).
+ trustedSendersBookId: string | null;
+ trustedSenderEmails: string[];
+ trustedSendersLoaded: boolean;
+ trustedSendersLoading: boolean;
+
hydrate: () => Promise;
fetchAddressBooks: () => Promise;
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise;
@@ -45,11 +60,25 @@ export interface ContactsState {
updateContact: (id: string, changes: Partial) => Promise;
deleteContact: (id: string) => Promise;
bulkDelete: (ids: string[]) => Promise;
+ importContacts: (contacts: Partial[], addressBookId: string) => Promise;
addContactKeyword: (id: string, keyword: string) => Promise;
removeContactKeyword: (id: string, keyword: string) => Promise;
+ addKeywordToContacts: (ids: string[], keyword: string) => Promise;
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise;
+ // Address book management
+ createAddressBook: (name: string) => Promise;
+ renameAddressBook: (id: string, name: string) => Promise;
+ deleteAddressBook: (id: string) => Promise;
+
+ // Trusted senders address book. Passive loads (createIfMissing=false) only
+ // read an existing book; the book is created lazily on the first add.
+ loadTrustedSendersBook: (createIfMissing?: boolean) => Promise;
+ addToTrustedSendersBook: (email: string) => Promise;
+ removeFromTrustedSendersBook: (email: string) => Promise;
+ isTrustedAddressBookSender: (email: string) => boolean;
+
setSelectedCategory: (category: ContactCategory) => void;
reset: () => void;
}
@@ -70,6 +99,11 @@ export const useContactsStore = create()(
error: null,
hydrated: false,
+ trustedSendersBookId: null,
+ trustedSenderEmails: [],
+ trustedSendersLoaded: false,
+ trustedSendersLoading: false,
+
hydrate: async () => {
if (get().hydrated) return;
try {
@@ -160,6 +194,22 @@ export const useContactsStore = create()(
set({ contacts: get().contacts.filter((c) => !idSet.has(c.id)) });
},
+ importContacts: async (incoming, addressBookId) => {
+ let imported = 0;
+ for (const contact of incoming) {
+ try {
+ // Strip the temporary client-side id; the server assigns a real one.
+ const { id: _id, addressBookIds: _abIds, ...data } = contact;
+ const created = await apiCreateContact(data, addressBookId);
+ set({ contacts: [...get().contacts, created] });
+ imported++;
+ } catch (err) {
+ console.warn('[contacts-store] import failed for one contact', err);
+ }
+ }
+ return imported;
+ },
+
addContactKeyword: async (id, keyword) => {
const kw = keyword.trim();
if (!kw) return;
@@ -176,12 +226,115 @@ export const useContactsStore = create()(
await get().updateContact(id, { keywords: rest });
},
+ addKeywordToContacts: async (ids, keyword) => {
+ const kw = keyword.trim();
+ if (!kw) return;
+ for (const id of ids) {
+ await get().addContactKeyword(id, kw);
+ }
+ },
+
moveContactsToAddressBook: async (ids, addressBookId) => {
for (const id of ids) {
await get().updateContact(id, { addressBookIds: { [addressBookId]: true } });
}
},
+ createAddressBook: async (name) => {
+ const trimmed = name.trim();
+ if (!trimmed) throw new Error('Address book name is required');
+ const book = await apiCreateAddressBook(trimmed);
+ set({ addressBooks: [...get().addressBooks, book] });
+ return book;
+ },
+
+ renameAddressBook: async (id, name) => {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ await apiUpdateAddressBook(id, { name: trimmed });
+ set({
+ addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)),
+ });
+ },
+
+ deleteAddressBook: async (id) => {
+ await apiDeleteAddressBook(id);
+ set({
+ addressBooks: get().addressBooks.filter((b) => b.id !== id),
+ // Drop the deleted book id from any cached contact so the UI doesn't
+ // keep filtering against a book that no longer exists.
+ contacts: get().contacts.map((c) => {
+ if (!c.addressBookIds?.[id]) return c;
+ const { [id]: _gone, ...rest } = c.addressBookIds;
+ return { ...c, addressBookIds: rest };
+ }),
+ });
+ const category = get().selectedCategory;
+ if (category.type === 'addressBook' && category.addressBookId === id) {
+ get().setSelectedCategory({ type: 'all' });
+ }
+ },
+
+ loadTrustedSendersBook: async (createIfMissing = false) => {
+ if (!jmapClient.isConnected || get().trustedSendersLoading) return;
+ set({ trustedSendersLoading: true });
+ try {
+ const books = (await fetchAddressBooks()) ?? [];
+ let book = books.find((b) => b.name === TRUSTED_SENDERS_BOOK_NAME);
+ if (!book) {
+ if (!createIfMissing) {
+ set({ trustedSendersLoaded: true, trustedSendersLoading: false });
+ return;
+ }
+ book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME);
+ }
+ const bookId = book.id;
+ const cards = await getContactsInBook(bookId);
+ const emails = cards.flatMap((c) =>
+ c.emails ? Object.values(c.emails).map((e) => e.address.toLowerCase().trim()) : [],
+ ).filter(Boolean);
+ set({
+ trustedSendersBookId: bookId,
+ trustedSenderEmails: emails,
+ trustedSendersLoaded: true,
+ trustedSendersLoading: false,
+ });
+ } catch (err) {
+ console.warn('[contacts-store] load trusted senders failed', err);
+ set({ trustedSendersLoaded: true, trustedSendersLoading: false });
+ }
+ },
+
+ addToTrustedSendersBook: async (email) => {
+ const normalized = email.toLowerCase().trim();
+ if (!normalized || get().trustedSenderEmails.includes(normalized)) return;
+
+ let bookId = get().trustedSendersBookId;
+ if (!bookId) {
+ await get().loadTrustedSendersBook(true);
+ bookId = get().trustedSendersBookId;
+ }
+ if (!bookId) throw new Error('Could not find or create the trusted senders address book');
+
+ await apiCreateContact({ emails: { email: { address: normalized } } }, bookId);
+ set({ trustedSenderEmails: [...get().trustedSenderEmails, normalized] });
+ },
+
+ removeFromTrustedSendersBook: async (email) => {
+ const normalized = email.toLowerCase().trim();
+ const bookId = get().trustedSendersBookId;
+ if (!bookId) return;
+ const cards = await getContactsInBook(bookId);
+ const match = cards.find((c) =>
+ c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase().trim() === normalized),
+ );
+ if (match) await apiDeleteContacts([match.id]);
+ set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== normalized) });
+ },
+
+ isTrustedAddressBookSender: (email) =>
+ get().trustedSenderEmails.includes(email.toLowerCase().trim()),
+
setSelectedCategory: (category) => {
set({ selectedCategory: category });
persistCategory(category);
@@ -193,6 +346,10 @@ export const useContactsStore = create()(
selectedCategory: { type: 'all' },
loading: false,
error: null,
+ trustedSendersBookId: null,
+ trustedSenderEmails: [],
+ trustedSendersLoaded: false,
+ trustedSendersLoading: false,
}),
}),
{
diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts
index 6a912010..528818ec 100644
--- a/src/stores/email-store.ts
+++ b/src/stores/email-store.ts
@@ -15,9 +15,12 @@ import {
getEmailChanges,
getFullEmail,
setEmailKeywords,
+ setKeywordsForEmails,
moveEmail,
+ moveEmails as apiMoveEmails,
archiveEmails as apiArchiveEmails,
deleteEmail as apiDeleteEmail,
+ deleteEmails as apiDeleteEmails,
restoreEmailMailboxes,
searchEmails as apiSearchEmails,
} from '../api/email';
@@ -130,6 +133,11 @@ export interface EmailState {
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise;
archiveEmail: (emailId: string) => Promise;
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise;
+ // ── Batch (multi-select) actions ──────────────────────────────
+ archiveEmailsBatch: (emailIds: string[]) => Promise;
+ moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise;
+ deleteEmailsBatch: (emailIds: string[], trashMailboxId: string, currentMailboxId: string) => Promise;
+ setKeywordForEmails: (emailIds: string[], token: string, on: boolean) => Promise;
undoLast: () => Promise;
clearUndo: () => void;
searchEmails: (query: string) => Promise;
@@ -957,6 +965,133 @@ export const useEmailStore = create()(
}
},
+ // ── Batch actions ─────────────────────────────────────────────
+ // Each produces a single combined UndoEntry (UndoEntry.items is an array),
+ // so a multi-select archive/move/delete is reversed with one snackbar tap.
+
+ archiveEmailsBatch: async (emailIds) => {
+ const { emails, mailboxes } = get();
+ const archiveMailbox = mailboxes.find(
+ (m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
+ );
+ if (!archiveMailbox) return;
+ const targets = emails.filter(
+ (e) => emailIds.includes(e.id) && !e.mailboxIds?.[archiveMailbox.id],
+ );
+ if (targets.length === 0) return;
+
+ const mode = useSettingsStore.getState().archiveMode;
+ const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
+
+ await apiArchiveEmails(
+ targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
+ archiveMailbox.id,
+ mode,
+ mailboxes,
+ );
+
+ const removed = new Set(targets.map((e) => e.id));
+ set({
+ emails: get().emails.filter((e) => !removed.has(e.id)),
+ pendingUndo: {
+ kind: 'archive',
+ label: targets.length === 1 ? 'Email archived' : `${targets.length} emails archived`,
+ createdAt: Date.now(),
+ items,
+ },
+ });
+
+ if (mode !== 'single') void get().fetchMailboxes();
+ },
+
+ moveEmailsToMailbox: async (emailIds, toMailboxId) => {
+ const { emails, currentMailboxId, mailboxes } = get();
+ if (!currentMailboxId || toMailboxId === currentMailboxId) return;
+ const targets = emails.filter((e) => emailIds.includes(e.id));
+ if (targets.length === 0) return;
+
+ const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
+
+ await apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId);
+
+ const removed = new Set(targets.map((e) => e.id));
+ const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name;
+ set({
+ emails: get().emails.filter((e) => !removed.has(e.id)),
+ pendingUndo: {
+ kind: 'move',
+ label: targetName
+ ? `${targets.length === 1 ? 'Email' : `${targets.length} emails`} moved to ${targetName}`
+ : 'Emails moved',
+ createdAt: Date.now(),
+ items,
+ },
+ });
+ },
+
+ deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => {
+ const { emails, mailboxes } = get();
+ const settings = useSettingsStore.getState();
+ const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
+ const inTrash = currentMailboxId === trashMailboxId;
+ const targets = emails.filter((e) => emailIds.includes(e.id));
+ if (targets.length === 0) return;
+
+ // Split into permanent-destroy vs move-to-trash following the same policy
+ // as the single delete: trash folder, global "permanent" default, or the
+ // skip-trash-for-junk option each force a destroy.
+ const toDestroy: Email[] = [];
+ const toTrash: Email[] = [];
+ for (const e of targets) {
+ const inJunk = !!(junkMailbox && e.mailboxIds?.[junkMailbox.id]);
+ const destroy =
+ inTrash ||
+ settings.deleteAction === 'permanent' ||
+ (settings.permanentlyDeleteJunk && inJunk);
+ (destroy ? toDestroy : toTrash).push(e);
+ }
+
+ if (toDestroy.length > 0) {
+ await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
+ }
+ if (toTrash.length > 0) {
+ await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
+ }
+
+ const removed = new Set(targets.map((e) => e.id));
+ set({ emails: get().emails.filter((e) => !removed.has(e.id)) });
+
+ // Only the moved-to-trash items are recoverable; destroyed ones are gone.
+ if (toTrash.length > 0) {
+ set({
+ pendingUndo: {
+ kind: 'delete',
+ label: toTrash.length === 1 ? 'Email moved to Trash' : `${toTrash.length} emails moved to Trash`,
+ createdAt: Date.now(),
+ items: toTrash.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })),
+ },
+ });
+ }
+ },
+
+ setKeywordForEmails: async (emailIds, token, on) => {
+ const targets = get().emails.filter((e) => emailIds.includes(e.id));
+ if (targets.length === 0) return;
+ const updates = targets.map((e) => {
+ const keywords = { ...e.keywords };
+ if (on) keywords[token] = true;
+ else delete keywords[token];
+ return { id: e.id, keywords };
+ });
+ await setKeywordsForEmails(updates);
+ const byId = new Map(updates.map((u) => [u.id, u.keywords]));
+ set({
+ emails: get().emails.map((e) =>
+ byId.has(e.id) ? { ...e, keywords: byId.get(e.id)! } : e,
+ ),
+ });
+ },
+
undoLast: async () => {
const entry = get().pendingUndo;
if (!entry) return;
diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts
index 1df677ec..e73bd488 100644
--- a/src/stores/settings-store.ts
+++ b/src/stores/settings-store.ts
@@ -68,6 +68,9 @@ interface PersistedSettings {
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
plainTextMode: boolean;
+ // Undo-send window: every send is deferred by this many seconds (via the
+ // server's FUTURERELEASE support) so it can be cancelled. 0 = send instantly.
+ sendDelaySeconds: number;
// Reading
markAsReadDelay: number;
@@ -161,6 +164,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
attachmentReminderEnabled: true,
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
plainTextMode: false,
+ sendDelaySeconds: 0,
markAsReadDelay: 0,
deleteAction: 'trash',