Compare commits

..
8 Commits
54 changed files with 7556 additions and 300 deletions
+4
View File
@@ -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() {
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen
name="AddAccount"
options={{
+1 -1
View File
@@ -1 +1 @@
0.1.35
0.1.39
+1
View File
@@ -20,6 +20,7 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
+13 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.35",
"version": "0.1.39",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.35",
"version": "0.1.39",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -25,6 +25,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -5418,6 +5419,16 @@
"react-native": "*"
}
},
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.35",
"version": "0.1.39",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -31,6 +31,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
+57
View File
@@ -16,6 +16,9 @@ import {
createContact,
updateContact,
deleteContacts,
createAddressBook,
updateAddressBook,
deleteAddressBook,
} from '../contacts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -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');
});
});
});
+139 -9
View File
@@ -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<CalendarEvent[]> {
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<string, unknown>,
sendSchedulingMessages?: boolean,
): Record<string, unknown> {
const args: Record<string, unknown> = { accountId, ...payload };
if (sendSchedulingMessages !== undefined) {
args.sendSchedulingMessages = sendSchedulingMessages;
}
return args;
}
export async function createEvent(
event: Partial<CalendarEvent>,
calendarId: string,
sendSchedulingMessages?: boolean,
): Promise<CalendarEvent> {
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<string, CalendarEvent>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(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<CalendarEvent>[],
calendarId: string,
): Promise<number> {
if (events.length === 0) return 0;
const accountId = jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
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<string, unknown> }>(res);
return result.created ? Object.keys(result.created).length : 0;
}
export async function updateEvent(
id: string,
changes: Partial<CalendarEvent>,
changes: Partial<CalendarEvent> | Record<string, unknown>,
sendSchedulingMessages?: boolean,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(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<void> {
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<void> {
// 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<string, string> | null,
): Promise<void> {
const accountId = jmapClient.accountId;
// Escape per RFC 6901: ~ → ~0, / → ~1.
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patch: Record<string, unknown> = {
[`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<string, { description?: string; type?: string }> }>(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<Partial<CalendarEvent>[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
USING,
);
const result = methodResult<{
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
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<Calendar> {
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<string, Calendar>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(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<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
USING,
);
}
+55
View File
@@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise<void> {
USING,
);
}
export async function createAddressBook(name: string): Promise<AddressBook> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, AddressBook>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(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<AddressBook>,
): Promise<void> {
const accountId = jmapClient.accountId;
// Only forward server-settable properties.
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
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<string, { description?: string; type?: string }> }>(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<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(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<ContactCard[]> {
const ids = await queryContacts({ inAddressBook: addressBookId });
return getContacts(ids);
}
+210 -3
View File
@@ -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<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
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<void> {
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<string, boolean> }>,
): Promise<void> {
if (updates.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { keywords: Record<string, boolean> }> = {};
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<void> {
// 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<SendEmailResult> {
const accountId = jmapClient.accountId;
const emailCreate: Record<string, unknown> = {
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<string, unknown> = { 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<string, { description?: string; type?: string }> }).notCreated?.draft;
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message');
emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
}
if (methodName === 'EmailSubmission/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1'];
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message');
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).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<ScheduledEmail[]> {
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<void> {
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');
}
}
+205 -17
View File
@@ -8,6 +8,11 @@ import type {
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
import { secureFetch } from '../lib/client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
// Refresh OAuth access tokens this many ms before they actually expire so
// in-flight requests don't race the expiry window.
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
@@ -22,6 +27,13 @@ export interface StoredCredentials {
username: string;
password: string;
accessToken?: string;
// OAuth-only — present when credentials came in via the webmail handoff
// OAuth path rather than password auth. The token endpoint and client id
// are needed for refresh; without them the access token would just expire.
refreshToken?: string;
expiresAt?: number;
tokenEndpoint?: string;
clientId?: string;
}
export class JMAPClient {
@@ -93,6 +105,113 @@ export class JMAPClient {
return this.session;
}
// OAuth login via webmail handoff. The webmail did the OAuth dance against
// Stalwart and handed us a complete token bundle. We persist the bundle
// so subsequent launches can refresh without prompting the user again.
async connectWithOAuth(
serverUrl: string,
tokens: OAuthTokens,
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = {
serverUrl: baseUrl,
username: '',
password: '',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session);
// The JMAP session document carries the authenticated user's identifier
// — use it as the username so per-account storage keys are stable across
// restarts and OAuth re-logins.
const username = this.session.username || tokens.accessToken.slice(0, 8);
this.credentials.username = username;
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
return { session: this.session, username, accountId };
}
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
if (!this.credentials) return;
this.credentials = {
...this.credentials,
accessToken: next.accessToken,
// Some IdPs rotate refresh tokens; others reuse. Fall back to the
// existing one when the response omits it.
refreshToken: next.refreshToken ?? this.credentials.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
const accountId = generateAccountId(
this.credentials.username,
this.credentials.serverUrl,
);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
}
private currentOAuthTokens(): OAuthTokens | null {
if (
!this.credentials?.accessToken ||
!this.credentials.refreshToken ||
!this.credentials.tokenEndpoint ||
!this.credentials.clientId
) {
return null;
}
return {
accessToken: this.credentials.accessToken,
refreshToken: this.credentials.refreshToken,
expiresAt: this.credentials.expiresAt,
tokenEndpoint: this.credentials.tokenEndpoint,
clientId: this.credentials.clientId,
};
}
// Proactive refresh: when the access token is about to expire, swap it for
// a fresh one. Quiet no-op for password credentials.
private async ensureFreshToken(): Promise<void> {
const tokens = this.currentOAuthTokens();
if (!tokens) return;
if (tokens.expiresAt == null) return;
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
} catch {
// Surface as AuthenticationError on the next 401; refresh may be
// temporarily failing (network) and the reactive retry path catches it.
}
}
// Reactive refresh after a 401. Returns true if a fresh token was obtained
// so the caller can retry the original request.
private async forceRefreshToken(): Promise<boolean> {
const tokens = this.currentOAuthTokens();
if (!tokens) return false;
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
return true;
} catch {
return false;
}
}
// Legacy single-slot restore - kept for backward-compat tests. New code
// should use loadAccount(accountId) driven by the account registry.
async restoreSession(): Promise<boolean> {
@@ -235,12 +354,18 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Invalid credentials');
@@ -280,14 +405,21 @@ export class JMAPClient {
methodCalls,
};
const response = await secureFetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(this.session!.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Session expired');
@@ -319,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<string, unknown>).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<StoredCredentials | null> {
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<void> {
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
}
// ── Blob Download ─────────────────────────────────────
// Expand the RFC 6570 level-1 template the JMAP server advertises.
@@ -335,9 +520,12 @@ export class JMAPClient {
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await secureFetch(url, {
headers: { Authorization: this.authHeader },
});
await this.ensureFreshToken();
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) throw new AuthenticationError('Session expired');
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
return response.arrayBuffer();
+72 -3
View File
@@ -9,6 +9,10 @@ export interface JMAPSession {
accounts: Record<string, JMAPAccountInfo>;
capabilities: Record<string, unknown>;
state: string;
// RFC 8620 §2 — authenticated user identifier. Optional because not all
// servers populate it; OAuth login uses it as the account label when
// there's no password username to fall back on.
username?: string;
}
export interface JMAPAccountInfo {
@@ -284,6 +288,13 @@ export interface ContactCryptoKey {
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry' | string;
mediaType?: string;
pref?: number;
}
export interface ContactPronouns {
pronouns: string;
pref?: number;
@@ -318,12 +329,15 @@ export interface ContactCard {
relatedTo?: Record<string, ContactRelation>;
links?: Record<string, ContactLink>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
speakToAs?: ContactSpeakToAs;
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
}
@@ -352,13 +366,39 @@ export interface AddressBookWithCount extends AddressBook {
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
export interface Participant {
'@type'?: 'Participant';
name?: string;
email?: string;
sendTo?: Record<string, string>;
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
calendarAddress?: string;
kind?: string;
roles?: Record<string, boolean>;
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<string, boolean>;
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<string, boolean>;
}
export interface RecurrenceRule {
@@ -399,27 +439,56 @@ export interface CalendarEvent {
status?: string;
freeBusyStatus?: string;
participants?: Record<string, Participant>;
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
replyTo?: Record<string, string>;
organizerCalendarAddress?: string;
sequence?: number;
recurrenceRules?: RecurrenceRule[];
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
excludedRecurrenceRules?: RecurrenceRule[];
recurrenceId?: string;
originalId?: string;
useDefaultAlerts?: boolean;
alerts?: Record<string, Alert>;
locations?: Record<string, EventLocation>;
virtualLocations?: Record<string, VirtualLocation>;
links?: Record<string, { href: string; rel?: string }>;
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<string, boolean>;
myRights?: CalendarRights;
}
// ─── Files (FileNode) ───────────────────────────────────
+196
View File
@@ -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<string, string>;
}
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<StoredCredentials> {
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<Array<[string, Record<string, any>, 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<UnifiedEmail[]> {
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<UnifiedInboxResult> {
const errors: Record<string, string> = {};
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 };
}
+16
View File
@@ -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);
}
};
+30
View File
@@ -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)
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox'); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>All inboxes</Text>
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>Scheduled</Text>
</Pressable>
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
{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,
+142
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>Tag {selectedEmails.length} selected</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{keywords.length === 0 ? (
<Text style={styles.empty}>No tags defined. Add tags in Settings.</Text>
) : (
keywords.map((kw) => {
const token = keywordToken(kw.id);
const applied = allHaveToken(token);
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
return (
<Pressable
key={kw.id}
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={dot} fill={dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
{applied && <Check size={16} color={c.primary} />}
</Pressable>
);
})
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
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 },
});
}
@@ -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) && (
<View style={styles.actionsSection}>
{onManageSubscriptions && (
<Pressable
onPress={onManageSubscriptions}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Rss size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Subscriptions</Text>
</Pressable>
)}
{onImport && (
<Pressable
onPress={onImport}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Upload size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Import from file</Text>
</Pressable>
)}
</View>
)}
</ScrollView>
</SafeAreaView>
</Animated.View>
@@ -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 },
});
}
+145 -19
View File
@@ -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<void>;
}
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 (
<Modal
@@ -198,6 +216,22 @@ export function EventDetailSheet({
showsVerticalScrollIndicator={false}
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
) : null}
{videoUri ? (
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<Video size={16} color={c.textMuted} />
</View>
<Pressable
style={styles.joinBtn}
onPress={() => { void Linking.openURL(videoUri); }}
>
<Text style={styles.joinBtnText}>Join video call</Text>
</Pressable>
</View>
) : null}
{event.description ? (
<DetailRow
icon={<AlignLeft size={16} color={c.textMuted} />}
@@ -211,10 +245,10 @@ export function EventDetailSheet({
text={recurrence}
/>
)}
{alert && (
{reminders.length > 0 && (
<DetailRow
icon={<Bell size={16} color={c.textMuted} />}
text={alert}
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
/>
)}
{calendar && (
@@ -224,6 +258,38 @@ export function EventDetailSheet({
/>
)}
{canRsvp && (
<View style={styles.rsvpBlock}>
<Text style={styles.rsvpPrompt}>Going?</Text>
<View style={styles.rsvpButtons}>
<RsvpButton
label="Yes"
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
active={myStatus === 'accepted'}
activeColor={c.success}
disabled={rsvpBusy}
onPress={() => doRsvp('accepted')}
/>
<RsvpButton
label="Maybe"
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
active={myStatus === 'tentative'}
activeColor={c.warning}
disabled={rsvpBusy}
onPress={() => doRsvp('tentative')}
/>
<RsvpButton
label="No"
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
active={myStatus === 'declined'}
activeColor={c.error}
disabled={rsvpBusy}
onPress={() => doRsvp('declined')}
/>
</View>
</View>
)}
{participants.length > 0 && (
<View style={styles.participantsBlock}>
<View style={styles.participantsHeader}>
@@ -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 (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.rsvpBtn,
active && { backgroundColor: activeColor, borderColor: activeColor },
disabled && { opacity: 0.5 },
]}
>
{icon}
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
</Pressable>
);
}
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,
+147 -70
View File
@@ -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<string, EventLocation> | undefined {
const trimmed = name.trim();
if (!trimmed) return undefined;
return { 'loc-1': { '@type': 'Location', name: trimmed } };
}
function buildVirtualLocations(uri: string): Record<string, VirtualLocation> | 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<string>(defaultCalendarId || calendars[0]?.id || '');
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
const [alert, setAlert] = React.useState<AlertOption>('none');
const [reminders, setReminders] = React.useState<Reminder[]>([]);
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 (
<Modal
@@ -423,42 +442,80 @@ export function EventModal({
)}
</Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
<Pressable
style={styles.fieldButton}
onPress={() => setAlertOpen((v) => !v)}
>
<Text style={styles.fieldText}>{alertLabel}</Text>
</Pressable>
{alertOpen && (
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
{reminders.length > 0 && (
<View style={{ gap: spacing.xs }}>
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
<Pressable
onPress={() => removeReminder(r.minutesBefore)}
hitSlop={8}
style={styles.reminderRemove}
>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
))}
</View>
)}
{availablePresets.length > 0 && (
<Pressable
style={styles.addRow}
onPress={() => setReminderPickerOpen((v) => !v)}
>
<Plus size={16} color={c.primary} />
<Text style={styles.addRowText}>
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
</Text>
</Pressable>
)}
{reminderPickerOpen && (
<View style={styles.popover}>
{ALERT_OPTIONS.map((opt) => (
{availablePresets.map((preset) => (
<Pressable
key={opt.value}
style={[
styles.popoverRow,
opt.value === alert && styles.popoverRowActive,
]}
onPress={() => {
setAlert(opt.value);
setAlertOpen(false);
}}
key={preset}
style={styles.popoverRow}
onPress={() => addReminder(preset)}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
</Pressable>
))}
</View>
)}
</Section>
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location">
<TextInput
value={location}
onChangeText={setLocation}
placeholder="Place or address"
placeholderTextColor={c.textMuted}
style={styles.fieldButton}
/>
</Section>
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call">
<TextInput
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.fieldButton}
/>
</Section>
<Section
icon={<MapPin size={16} color={c.textMuted} />}
icon={<AlignLeft size={16} color={c.textMuted} />}
label="Description"
>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="Notes, location, link…"
placeholder="Notes, agenda…"
placeholderTextColor={c.textMuted}
multiline
style={styles.descriptionInput}
@@ -629,6 +686,26 @@ function makeStyles(c: ThemePalette) {
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
reminderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
reminderRemove: { padding: 2 },
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
},
addRowText: { ...typography.body, color: c.primary },
popover: {
borderWidth: 1,
borderColor: c.border,
+391
View File
@@ -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 { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { uploadBlob } from '../../api/blob';
import { parseCalendarBlob } from '../../api/calendar';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet 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;
calendars: Calendar[];
/** Imports the selected events into the chosen calendar; returns count. */
onImport: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
onImported?: (count: number) => void;
}
function eventDateLabel(event: Partial<CalendarEvent>): 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<Partial<CalendarEvent>[]>([]);
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [calendarId, setCalendarId] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [importing, setImporting] = React.useState(false);
const [result, setResult] = React.useState<number | null>(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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>Import calendar</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
{result !== null ? (
<View style={styles.center}>
<View style={styles.successBadge}>
<Check size={26} color={c.primaryForeground} />
</View>
<Text style={styles.successText}>
Imported {result} event{result === 1 ? '' : 's'}.
</Text>
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
</View>
) : parsed.length === 0 ? (
<View style={styles.center}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }} disabled={loading}>
{loading ? (
<ActivityIndicator color={c.primary} />
) : (
<Upload size={32} color={c.textMuted} />
)}
<Text style={styles.dropTitle}>{loading ? 'Parsing…' : 'Choose an .ics file'}</Text>
<Text style={styles.dropHint}>iCalendar — single or multiple events</Text>
</Pressable>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
) : (
<>
{writable.length > 1 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.calChips}>
{writable.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
<View style={styles.toolbar}>
<Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected
</Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}><Text style={styles.link}>All</Text></Pressable>
<Pressable onPress={selectNone} hitSlop={6}><Text style={styles.link}>None</Text></Pressable>
</View>
</View>
<ScrollView style={{ flex: 1 }}>
{parsed.map((event, idx) => {
const isSelected = selected.has(idx);
const date = eventDateLabel(event);
return (
<Pressable
key={idx}
onPress={() => toggle(idx)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected && <Check size={13} color={c.primaryForeground} />}
</View>
<Clock size={16} color={c.textMuted} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text>
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
</View>
</Pressable>
);
})}
</ScrollView>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
<Button
onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />}
>
{importing ? 'Importing…' : `Import ${selected.size}`}
</Button>
</View>
</>
)}
</SafeAreaView>
</Animated.View>
</Modal>
);
}
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,
},
});
}
@@ -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<string>(CALENDAR_COLOR_PALETTE[0]);
const [adding, setAdding] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
<Text style={styles.title}>Calendar subscriptions</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
<Text style={styles.sectionLabel}>Add a feed</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Name (e.g. Holidays)"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<TextInput
value={url}
onChangeText={setUrl}
placeholder="https://… or webcal://…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.input}
/>
<View style={styles.colorRow}>
{CALENDAR_COLOR_PALETTE.map((col) => (
<Pressable
key={col}
onPress={() => setColor(col)}
style={[
styles.colorDot,
{ backgroundColor: col },
color === col && styles.colorDotActive,
]}
/>
))}
</View>
{!!formError && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{formError}</Text>
</View>
)}
<Button
onPress={() => { void handleAdd(); }}
disabled={adding}
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
>
{adding ? 'Subscribing…' : 'Subscribe'}
</Button>
{subscriptions.length > 0 && (
<>
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
{subscriptions.map((sub) => {
const busy = !!syncing[sub.id];
return (
<View key={sub.id} style={styles.subRow}>
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
<Text style={styles.subMeta} numberOfLines={1}>
{sub.lastError
? sub.lastError
: sub.lastSyncAt
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
: 'Not synced yet'}
</Text>
</View>
<Pressable
onPress={() => { void syncSubscription(sub.id); }}
hitSlop={6}
style={styles.subBtn}
disabled={busy}
>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<RefreshCw size={16} color={c.textSecondary} />
)}
</Pressable>
<Pressable
onPress={() => { void removeSubscription(sub.id); }}
hitSlop={6}
style={styles.subBtn}
>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
);
})}
</>
)}
</ScrollView>
<View style={{ height: insets.bottom }} />
</SafeAreaView>
</Animated.View>
</Modal>
);
}
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 },
});
}
+371
View File
@@ -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<CalendarEvent>, calendarId: string) => Promise<void> | void;
onToggle: (id: string) => Promise<void> | void;
onDelete: (id: string) => Promise<void> | 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<Date | null>(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<CalendarEvent> = {
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 (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<ListChecks size={20} color={c.text} />
<Text style={styles.headerTitle}>Tasks</Text>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={c.textMuted} />
</Pressable>
</View>
</View>
<View style={styles.addRow}>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Add a task…"
placeholderTextColor={c.textMuted}
style={styles.addInput}
returnKeyType="done"
onSubmitEditing={() => { void handleAdd(); }}
/>
<Pressable
onPress={() => setShowDuePicker(true)}
style={[styles.dueChip, due && styles.dueChipActive]}
hitSlop={6}
>
<CalendarIcon size={14} color={due ? c.primary : c.textMuted} />
{due && <Text style={styles.dueChipText}>{format(due, 'MMM d')}</Text>}
</Pressable>
<Pressable
onPress={() => { void handleAdd(); }}
disabled={!title.trim() || !calendarId || saving}
style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]}
>
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
{writableCalendars.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.calChips}
>
{writableCalendars.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{sorted.length === 0 ? (
<View style={styles.empty}>
<ListChecks size={32} color={c.surfaceActive} />
<Text style={styles.emptyText}>No tasks yet</Text>
</View>
) : (
sorted.map((task) => {
const completed = isCompleted(task);
const due = dueLabel(task);
return (
<View key={task.id} style={styles.taskRow}>
<Pressable
onPress={() => { void onToggle(task.id); }}
style={[styles.checkbox, completed && styles.checkboxChecked]}
hitSlop={8}
>
{completed && <Check size={14} color={c.primaryForeground} />}
</Pressable>
<View style={styles.taskText}>
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
{task.title || 'Untitled task'}
</Text>
{due && <Text style={styles.taskDue}>{due}</Text>}
</View>
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
<Trash2 size={16} color={c.textMuted} />
</Pressable>
</View>
);
})
)}
</ScrollView>
</SafeAreaView>
</Animated.View>
{showDuePicker && (
<DateTimePicker
value={due ?? new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
setShowDuePicker(false);
if (d) setDue(d);
}}
/>
)}
</Modal>
);
}
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 },
});
}
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{addressBooks.map((book) => {
const isCurrent = book.id === currentBookId;
const canWrite = book.myRights?.mayWrite !== false;
const canTarget = canWrite && !isCurrent;
return (
<Pressable
key={book.id}
onPress={canTarget ? () => onPick(book.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]}
>
<BookUser size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text style={[styles.rowLabel, !canTarget && styles.rowLabelDisabled]} numberOfLines={1}>
{book.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
{creating ? (
<View style={styles.createRow}>
<TextInput
style={styles.createInput}
placeholder="New address book name"
placeholderTextColor={c.textMuted}
value={newName}
onChangeText={setNewName}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void handleCreate(); }}
/>
<Pressable
onPress={() => { void handleCreate(); }}
disabled={!newName.trim() || busy}
style={[styles.createBtn, (!newName.trim() || busy) && styles.createBtnDisabled]}
hitSlop={6}
>
<Check size={16} color={c.primaryForeground} />
</Pressable>
</View>
) : (
<Pressable
onPress={() => setCreating(true)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Plus size={16} color={c.primary} />
<Text style={[styles.rowLabel, { color: c.primary }]}>New address book…</Text>
</Pressable>
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
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 },
});
}
@@ -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<ContactCard[]>([]);
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
const [error, setError] = React.useState<string | null>(null);
const [importing, setImporting] = React.useState(false);
const [result, setResult] = React.useState<number | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
setParsed([]);
setSelected(new Set());
setDuplicates(new Map());
setError(null);
setImporting(false);
setResult(null);
}, []);
React.useEffect(() => {
if (visible) {
resetState();
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity, resetState]);
const pickFile = async () => {
const picker = loadDocumentPicker();
if (!picker) {
Alert.alert(
'Import unavailable',
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
);
return;
}
setError(null);
let res;
try {
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not open the file picker');
return;
}
if (res.canceled || res.assets.length === 0) return;
const asset = res.assets[0];
if (asset.size && asset.size > 5 * 1024 * 1024) {
setError('That file is too large (max 5 MB).');
return;
}
try {
const text = await FileSystem.readAsStringAsync(asset.uri);
const contacts = parseVCard(text);
if (contacts.length === 0) {
setError('No contacts were found in that file.');
return;
}
const dupes = detectDuplicates(existing, contacts);
const initial = new Set<number>();
contacts.forEach((_, idx) => { if (!dupes.has(idx)) initial.add(idx); });
setParsed(contacts);
setDuplicates(dupes);
setSelected(initial);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
}
};
const toggle = (idx: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
const selectNone = () => setSelected(new Set());
const doImport = async () => {
if (!targetBookId) {
Alert.alert('No address book', 'Create an address book before importing contacts.');
return;
}
const toImport = parsed.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setImporting(true);
try {
const count = await importContacts(toImport, targetBookId);
setResult(count);
onImported?.(count);
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed.');
} finally {
setImporting(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>Import contacts</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
{result !== null ? (
<View style={styles.center}>
<View style={styles.successBadge}>
<Check size={26} color={c.primaryForeground} />
</View>
<Text style={styles.successText}>
Imported {result} contact{result === 1 ? '' : 's'}.
</Text>
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
</View>
) : parsed.length === 0 ? (
<View style={styles.center}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }}>
<Upload size={32} color={c.textMuted} />
<Text style={styles.dropTitle}>Choose a vCard file</Text>
<Text style={styles.dropHint}>.vcf / .vcard — single or multiple contacts</Text>
</Pressable>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
) : (
<>
<Pressable
style={styles.targetRow}
onPress={onChangeTarget}
disabled={!onChangeTarget}
>
<BookUser size={16} color={c.textSecondary} />
<Text style={styles.targetText} numberOfLines={1}>
Import into {targetBookName || 'address book'}
</Text>
{onChangeTarget && <Text style={styles.targetChange}>Change</Text>}
</Pressable>
<View style={styles.toolbar}>
<Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected
</Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}>
<Text style={styles.link}>All</Text>
</Pressable>
<Pressable onPress={selectNone} hitSlop={6}>
<Text style={styles.link}>None</Text>
</Pressable>
</View>
</View>
<ScrollView style={{ flex: 1 }}>
{parsed.map((contact, idx) => {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const isDupe = duplicates.has(idx);
const isSelected = selected.has(idx);
return (
<Pressable
key={idx}
onPress={() => toggle(idx)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected && <Check size={13} color={c.primaryForeground} />}
</View>
<FileText size={16} color={c.textMuted} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{name || email || '—'}</Text>
{!!email && !!name && (
<Text style={styles.rowEmail} numberOfLines={1}>{email}</Text>
)}
</View>
{isDupe && (
<Text style={styles.dupeBadge}>Duplicate</Text>
)}
</Pressable>
);
})}
</ScrollView>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
<Button
onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
>
{importing ? 'Importing…' : `Import ${selected.size}`}
</Button>
</View>
</>
)}
</SafeAreaView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '85%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.h3, color: c.text },
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
dropZone: {
width: '100%',
borderWidth: 2,
borderColor: c.border,
borderStyle: 'dashed',
borderRadius: radius.lg,
paddingVertical: spacing.xxxl,
alignItems: 'center',
gap: spacing.sm,
},
dropTitle: { ...typography.bodyMedium, color: c.text },
dropHint: { ...typography.caption, color: c.textMuted },
successBadge: {
width: 56, height: 56, borderRadius: radius.full,
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
},
successText: { ...typography.bodyMedium, color: c.text },
targetRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
targetText: { ...typography.caption, color: c.textSecondary, flex: 1 },
targetChange: { ...typography.captionMedium, color: c.primary },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
toolbarCount: { ...typography.caption, color: c.textMuted },
link: { ...typography.captionMedium, color: c.primary },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 48,
},
rowPressed: { backgroundColor: c.surfaceHover },
checkbox: {
width: 20, height: 20, borderRadius: radius.xs,
borderWidth: 1, borderColor: c.border,
alignItems: 'center', justifyContent: 'center',
},
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
rowName: { ...typography.body, color: c.text },
rowEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
dupeBadge: {
...typography.small,
color: c.warning,
backgroundColor: c.warningBg,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
overflow: 'hidden',
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: c.errorBorder,
},
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
});
}
+190
View File
@@ -0,0 +1,190 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Tag, X, Plus } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
interface Props {
visible: boolean;
onClose: () => void;
/** Called with the chosen tag (existing or freshly typed). */
onPick: (keyword: string) => void;
title?: string;
}
export default function TagAssignSheet({
visible, onClose, onPick, title = 'Add tag',
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const keywords = useContactsStore(selectKeywordsUsed);
const [input, setInput] = React.useState('');
const slideY = React.useRef(new Animated.Value(600)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
React.useEffect(() => {
if (visible) {
setInput('');
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const submitNew = () => {
const kw = input.trim();
if (!kw) return;
onPick(kw);
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="New tag name"
placeholderTextColor={c.textMuted}
value={input}
onChangeText={setInput}
autoCapitalize="none"
returnKeyType="done"
onSubmitEditing={submitNew}
/>
<Pressable
onPress={submitNew}
disabled={!input.trim()}
style={[styles.addBtn, !input.trim() && styles.addBtnDisabled]}
hitSlop={6}
>
<Plus size={16} color={c.primaryForeground} />
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{keywords.length === 0 ? (
<Text style={styles.empty}>No tags yet. Type one above.</Text>
) : (
keywords.map((kw) => (
<Pressable
key={kw.keyword}
onPress={() => onPick(kw.keyword)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={c.textSecondary} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.keyword}</Text>
<Text style={styles.rowCount}>{kw.count}</Text>
</Pressable>
))
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
input: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
addBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.primary,
borderRadius: radius.full,
},
addBtnDisabled: { opacity: 0.4 },
empty: { ...typography.caption, color: c.textMuted, textAlign: 'center', paddingVertical: spacing.xl },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 },
rowCount: { ...typography.caption, color: c.textMuted },
});
}
+3
View File
@@ -1,4 +1,7 @@
export { default as AddressBookPickerSheet } from './AddressBookPickerSheet';
export { default as ContactImportSheet } from './ContactImportSheet';
export { default as ContactListRow } from './ContactListRow';
export { default as ContactPickerSheet } from './ContactPickerSheet';
export { default as ContactsSidebarDrawer } from './ContactsSidebarDrawer';
export { default as TagAssignSheet } from './TagAssignSheet';
export { default as FieldBlock, FieldRow } from './FieldBlock';
@@ -0,0 +1,305 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native';
import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
} from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { parseCalendarBlob } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useAccountStore } from '../../stores/account-store';
import { useSettingsStore } from '../../stores/settings-store';
import {
findCalendarAttachment,
findParticipantByEmail,
getOrganizerName,
buildReplyTo,
inferInvitationMethod,
type InvitationMethod,
} from '../../lib/calendar-invitation';
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
interface Props {
email: Email;
}
export function CalendarInvitationBanner({ email }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const calendars = useCalendarStore((s) => s.calendars);
const importEvents = useCalendarStore((s) => s.importEvents);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
const [state, setState] = React.useState<BannerState>('loading');
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
const [method, setMethod] = React.useState<InvitationMethod>('unknown');
const [rsvpStatus, setRsvpStatus] = React.useState<RsvpStatus | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
if (!attachment || !enabled) return;
setState('loading');
(async () => {
try {
const events = await parseCalendarBlob(attachment.blobId);
if (cancelled) return;
if (events.length === 0) {
setState('error');
return;
}
const parsed = events[0];
setEvent(parsed);
setMethod(inferInvitationMethod(parsed));
setState('parsed');
} catch {
if (!cancelled) setState('error');
}
})();
return () => { cancelled = true; };
}, [attachment, enabled]);
if (!attachment || !enabled || state === 'error') return null;
if (state === 'loading') {
return (
<View style={styles.banner}>
<ActivityIndicator size="small" color={c.primary} />
<Text style={styles.loadingText}>Reading invitation…</Text>
</View>
);
}
if (!event) return null;
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
const startDate = startStr ? parseISO(startStr) : null;
const dateLabel = startDate && !isNaN(startDate.getTime())
? (event.showWithoutTime
? format(startDate, 'EEEE, MMM d, yyyy')
: format(startDate, 'EEE, MMM d · HH:mm'))
: null;
const organizer = getOrganizerName(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
if (busy || !writableCalendar) return;
setBusy(true);
setNotice(null);
try {
// Make sure the event exists in a local calendar (dedupes by UID).
await importEvents([event], writableCalendar.id);
// Re-read from the store to get the server-assigned id + participant.
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
const participant = stored && activeEmail
? findParticipantByEmail(stored, [activeEmail])
: me;
if (stored && participant) {
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
setRsvpStatus(status);
setNotice('Response sent');
} else {
setNotice('Added to calendar');
}
setState('done');
} catch {
setNotice('Could not send your response');
} finally {
setBusy(false);
}
};
const handleImport = async () => {
if (busy || !writableCalendar) return;
setBusy(true);
setNotice(null);
try {
const count = await importEvents([event], writableCalendar.id);
setNotice(count > 0 ? 'Added to calendar' : 'Already in your calendar');
setState('done');
} catch {
setNotice('Could not add the event');
} finally {
setBusy(false);
}
};
return (
<View style={styles.banner}>
<View style={styles.headerRow}>
<View style={styles.iconBadge}>
<CalendarDays size={18} color={c.primary} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title} numberOfLines={2}>{event.title || 'Calendar invitation'}</Text>
{method === 'cancel' && <Text style={styles.cancelled}>This event was cancelled</Text>}
</View>
</View>
{dateLabel && (
<Row icon={<Clock size={15} color={c.textMuted} />} text={dateLabel} styles={styles} />
)}
{location ? (
<Row icon={<MapPin size={15} color={c.textMuted} />} text={location} styles={styles} />
) : null}
{videoUri ? (
<Pressable style={styles.detailRow} onPress={() => { void Linking.openURL(videoUri); }}>
<Video size={15} color={c.textMuted} />
<Text style={[styles.detailText, { color: c.primary }]} numberOfLines={1}>Join video call</Text>
</Pressable>
) : null}
{organizer ? (
<Row icon={<CalendarPlus size={15} color={c.textMuted} />} text={`Organizer: ${organizer}`} styles={styles} />
) : null}
{notice && <Text style={styles.notice}>{notice}</Text>}
{state !== 'done' && (
<View style={styles.actions}>
{canRsvp ? (
<>
<RsvpBtn label="Yes" active={rsvpStatus === 'accepted'} activeColor={c.success}
icon={<Check size={15} color={rsvpStatus === 'accepted' ? c.textInverse : c.success} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} />
<RsvpBtn label="Maybe" active={rsvpStatus === 'tentative'} activeColor={c.warning}
icon={<HelpCircle size={15} color={rsvpStatus === 'tentative' ? c.textInverse : c.warning} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} />
<RsvpBtn label="No" active={rsvpStatus === 'declined'} activeColor={c.error}
icon={<X size={15} color={rsvpStatus === 'declined' ? c.textInverse : c.error} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
</>
) : (
<Pressable
style={[styles.importBtn, busy && { opacity: 0.5 }]}
onPress={() => { void handleImport(); }}
disabled={busy}
>
{busy ? (
<ActivityIndicator size="small" color={c.primaryForeground} />
) : (
<CalendarPlus size={16} color={c.primaryForeground} />
)}
<Text style={styles.importBtnText}>Add to calendar</Text>
</Pressable>
)}
</View>
)}
{!writableCalendar && state !== 'done' && (
<View style={styles.warnRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.warnText}>No writable calendar available</Text>
</View>
)}
</View>
);
}
function Row({
icon, text, styles,
}: {
icon: React.ReactNode;
text: string;
styles: ReturnType<typeof makeStyles>;
}) {
return (
<View style={styles.detailRow}>
{icon}
<Text style={styles.detailText} numberOfLines={2}>{text}</Text>
</View>
);
}
function RsvpBtn({
label, icon, active, activeColor, disabled, onPress, c, styles,
}: {
label: string;
icon: React.ReactNode;
active: boolean;
activeColor: string;
disabled?: boolean;
onPress: () => void;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
}) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.rsvpBtn,
active && { backgroundColor: activeColor, borderColor: activeColor },
disabled && { opacity: 0.5 },
]}
>
{icon}
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
</Pressable>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
gap: spacing.xs,
},
loadingText: { ...typography.caption, color: c.textMuted },
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.xs },
iconBadge: {
width: 32, height: 32, borderRadius: radius.sm,
backgroundColor: c.primaryBg, alignItems: 'center', justifyContent: 'center',
},
title: { ...typography.bodySemibold, color: c.text },
cancelled: { ...typography.caption, color: c.error, marginTop: 2 },
detailRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 2 },
detailText: { flex: 1, ...typography.caption, color: c.textSecondary },
notice: { ...typography.captionMedium, color: c.primary, marginTop: spacing.xs },
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
rsvpBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
rsvpBtnText: { ...typography.caption, color: c.text },
importBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: c.primary,
},
importBtnText: { ...typography.bodyMedium, color: c.primaryForeground },
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs },
warnText: { ...typography.caption, color: c.warning },
});
}
@@ -16,6 +16,8 @@ export function ComposingSettings() {
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -37,6 +39,14 @@ export function ComposingSettings() {
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
};
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
{ label: 'Off', value: 0 },
{ label: '5s', value: 5 },
{ label: '10s', value: 10 },
{ label: '20s', value: 20 },
{ label: '30s', value: 30 },
];
return (
<SettingsSection
title="Composing"
@@ -49,6 +59,29 @@ export function ComposingSettings() {
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<View style={styles.subBlock}>
<Text style={styles.subLabel}>Undo send delay</Text>
<Text style={styles.subDescription}>
Hold outgoing mail for a few seconds so you can cancel it. Requires server support.
</Text>
<View style={styles.segmentRow}>
{SEND_DELAY_OPTIONS.map((opt) => {
const active = sendDelaySeconds === opt.value;
return (
<Pressable
key={opt.value}
onPress={() => updateSetting('sendDelaySeconds', opt.value)}
style={[styles.segment, active && styles.segmentActive]}
>
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
<SettingItem
label="Attachment reminder"
description="Warn before sending if the message mentions an attachment but none is attached."
@@ -110,6 +143,19 @@ function makeStyles(c: ThemePalette) {
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground },
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
segment: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
segmentActive: { backgroundColor: c.primary, borderColor: c.primary },
segmentText: { ...typography.caption, color: c.text },
segmentTextActive: { color: c.primaryForeground, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
+271 -35
View File
@@ -1,30 +1,50 @@
import React from 'react';
import { Alert, Share, Text } from 'react-native';
import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { Download } from 'lucide-react-native';
import {
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { ContactImportSheet } from '../contacts';
import { useSettingsStore } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { typography, type ThemePalette } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export function ContactsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
const books = useContactsStore(selectAddressBooksWithCount);
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const createAddressBook = useContactsStore((s) => s.createAddressBook);
const renameAddressBook = useContactsStore((s) => s.renameAddressBook);
const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook);
const [exporting, setExporting] = React.useState(false);
const [importOpen, setImportOpen] = React.useState(false);
const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editName, setEditName] = React.useState('');
const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
const exportLabel =
exportable.length === 0
? 'No contacts to export'
: `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`;
const importTargetBookId = books[0]?.id ?? null;
const handleExport = async () => {
if (exportable.length === 0 || exporting) return;
@@ -50,40 +70,256 @@ export function ContactsSettings() {
}
};
const startRename = (id: string, name: string) => {
setAdding(false);
setEditingId(id);
setEditName(name);
};
const commitRename = async () => {
const name = editName.trim();
if (!editingId || !name || busy) { setEditingId(null); return; }
setBusy(true);
try {
await renameAddressBook(editingId, name);
setEditingId(null);
} catch (err) {
Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
const commitCreate = async () => {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
try {
await createAddressBook(name);
setAdding(false);
setNewName('');
} catch (err) {
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
const { id } = deleteTarget;
setDeleteTarget(null);
try {
await deleteAddressBook(id);
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error');
}
};
return (
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
<>
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={c.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
>
Export
</Button>
</SettingItem>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Import contacts"
description="Importing vCard files is currently available on the web client only."
<SettingItem
label="Import contacts"
description="Import contacts from a vCard (.vcf) file with duplicate detection."
>
<Button
variant="outline"
size="sm"
icon={<Upload size={14} color={c.text} />}
onPress={() => setImportOpen(true)}
disabled={books.length === 0}
>
Import
</Button>
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={c.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
>
Export
</Button>
</SettingItem>
</SettingsSection>
<SettingsSection
title="Address books"
description="Create, rename, and remove the address books that organize your contacts."
>
<Text style={[typography.caption, { color: c.textMuted }]}>Web only</Text>
</SettingItem>
</SettingsSection>
{books.map((book) => (
<View key={book.id} style={styles.bookRow}>
<BookUser size={16} color={c.textSecondary} />
{editingId === book.id ? (
<>
<TextInput
style={styles.bookInput}
value={editName}
onChangeText={setEditName}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void commitRename(); }}
placeholderTextColor={c.textMuted}
/>
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.iconBtn}>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => setEditingId(null)} hitSlop={6} style={styles.iconBtn}>
<X size={16} color={c.textMuted} />
</Pressable>
</>
) : (
<>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.bookName} numberOfLines={1}>{book.name}</Text>
<Text style={styles.bookCount}>
{book.count} contact{book.count === 1 ? '' : 's'}
</Text>
</View>
{book.myRights?.mayWrite !== false && (
<Pressable onPress={() => startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}>
<Pencil size={15} color={c.textSecondary} />
</Pressable>
)}
{book.myRights?.mayDelete !== false && books.length > 1 && (
<Pressable
onPress={() => setDeleteTarget({ id: book.id, name: book.name, count: book.count })}
hitSlop={6}
style={styles.iconBtn}
>
<Trash2 size={15} color={c.error} />
</Pressable>
)}
</>
)}
</View>
))}
{adding ? (
<View style={styles.bookRow}>
<BookUser size={16} color={c.textSecondary} />
<TextInput
style={styles.bookInput}
value={newName}
onChangeText={setNewName}
placeholder="New address book name"
placeholderTextColor={c.textMuted}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void commitCreate(); }}
/>
<Pressable
onPress={() => { void commitCreate(); }}
disabled={!newName.trim() || busy}
hitSlop={6}
style={styles.iconBtn}
>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
) : (
<Pressable
style={styles.addRow}
onPress={() => { setEditingId(null); setAdding(true); }}
>
<Plus size={16} color={c.primary} />
<Text style={styles.addLabel}>New address book</Text>
</Pressable>
)}
<View style={{ marginTop: spacing.sm }}>
<Button
variant="ghost"
size="sm"
onPress={() => { void fetchAddressBooks(); void fetchContacts(); }}
>
Refresh
</Button>
</View>
</SettingsSection>
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
targetBookId={importTargetBookId}
targetBookName={books[0]?.name}
onImported={() => { void fetchContacts(); }}
/>
<Dialog
visible={deleteTarget !== null}
title="Delete address book"
message={
deleteTarget
? `Delete "${deleteTarget.name}"?${deleteTarget.count > 0 ? ` Its ${deleteTarget.count} contact${deleteTarget.count === 1 ? '' : 's'} will be removed from this book.` : ''} This cannot be undone.`
: ''
}
variant="destructive"
confirmText="Delete"
onConfirm={() => { void confirmDelete(); }}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
bookRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
bookName: { ...typography.body, color: c.text },
bookCount: { ...typography.caption, color: c.textMuted, marginTop: 1 },
bookInput: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
iconBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
},
addLabel: { ...typography.body, color: c.primary },
});
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
offsetToMinutesBefore,
minutesBeforeToOffset,
alertsToReminders,
remindersToAlerts,
formatReminder,
} from '../calendar-alerts';
describe('calendar-alerts', () => {
describe('offsetToMinutesBefore', () => {
it('parses minute offsets', () => {
expect(offsetToMinutesBefore('-PT15M')).toBe(15);
expect(offsetToMinutesBefore('-PT30M')).toBe(30);
});
it('parses hour and day offsets', () => {
expect(offsetToMinutesBefore('-PT1H')).toBe(60);
expect(offsetToMinutesBefore('-P1D')).toBe(60 * 24);
});
it('treats PT0S as at-time (0)', () => {
expect(offsetToMinutesBefore('PT0S')).toBe(0);
});
it('returns negative minutesBefore for post-start offsets', () => {
expect(offsetToMinutesBefore('PT10M')).toBe(-10);
});
it('returns null for garbage', () => {
expect(offsetToMinutesBefore('nonsense')).toBeNull();
});
});
describe('minutesBeforeToOffset', () => {
it('round-trips through offsetToMinutesBefore', () => {
for (const m of [0, 5, 15, 30, 60, 120, 60 * 24, 60 * 24 * 7]) {
expect(offsetToMinutesBefore(minutesBeforeToOffset(m))).toBe(m);
}
});
it('emits PT0S at time of event', () => {
expect(minutesBeforeToOffset(0)).toBe('PT0S');
});
});
describe('alertsToReminders / remindersToAlerts', () => {
it('extracts and de-dupes reminders, sorted by lead time desc', () => {
const reminders = alertsToReminders({
a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
b: { trigger: { '@type': 'OffsetTrigger', offset: '-P1D' }, action: 'display' },
c: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
});
expect(reminders).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 15 }]);
});
it('builds an alerts map from reminders', () => {
const alerts = remindersToAlerts([{ minutesBefore: 15 }, { minutesBefore: 0 }]);
expect(alerts && Object.keys(alerts)).toHaveLength(2);
const offsets = Object.values(alerts!).map((a) => a.trigger.offset);
expect(offsets).toContain('-PT15M');
expect(offsets).toContain('PT0S');
});
it('returns undefined for an empty reminder list', () => {
expect(remindersToAlerts([])).toBeUndefined();
});
it('round-trips alerts → reminders → alerts', () => {
const reminders = [{ minutesBefore: 60 }, { minutesBefore: 10 }];
const back = alertsToReminders(remindersToAlerts(reminders));
expect(back).toEqual([{ minutesBefore: 60 }, { minutesBefore: 10 }]);
});
});
describe('formatReminder', () => {
it('formats common offsets', () => {
expect(formatReminder(0)).toBe('At time of event');
expect(formatReminder(15)).toBe('15 minutes before');
expect(formatReminder(60)).toBe('1 hour before');
expect(formatReminder(60 * 24)).toBe('1 day before');
expect(formatReminder(60 * 24 * 7)).toBe('1 week before');
});
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import {
parseVCard,
generateVCard,
contactToVCard,
detectDuplicates,
} from '../vcard';
import type { ContactCard } from '../../api/types';
describe('parseVCard', () => {
it('parses a basic 3.0 card with name, email, and phone', () => {
const vcf = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Jane Doe',
'N:Doe;Jane;;;',
'EMAIL;TYPE=WORK:jane@example.com',
'TEL;TYPE=CELL:+1-555-0100',
'END:VCARD',
].join('\r\n');
const [card] = parseVCard(vcf);
expect(card).toBeTruthy();
const emails = Object.values(card.emails ?? {});
expect(emails[0].address).toBe('jane@example.com');
expect(emails[0].contexts).toEqual({ work: true });
const phones = Object.values(card.phones ?? {});
expect(phones[0].number).toBe('+1-555-0100');
expect(phones[0].features).toEqual({ cell: true });
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
expect(given).toBe('Jane');
expect(surname).toBe('Doe');
});
it('parses multiple cards in one file', () => {
const vcf = [
'BEGIN:VCARD', 'VERSION:3.0', 'FN:A', 'EMAIL:a@x.com', 'END:VCARD',
'BEGIN:VCARD', 'VERSION:3.0', 'FN:B', 'EMAIL:b@x.com', 'END:VCARD',
].join('\r\n');
expect(parseVCard(vcf)).toHaveLength(2);
});
it('unfolds folded lines and decodes escaped values', () => {
const vcf = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Long Name',
'NOTE:line one\\nline two\\, still going',
'END:VCARD',
].join('\r\n');
const [card] = parseVCard(vcf);
const note = Object.values(card.notes ?? {})[0]?.note;
expect(note).toBe('line one\nline two, still going');
});
it('parses CATEGORIES into keywords', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tagged\r\nEMAIL:t@x.com\r\nCATEGORIES:Friends,VIP\r\nEND:VCARD';
const [card] = parseVCard(vcf);
expect(card.keywords).toEqual({ Friends: true, VIP: true });
});
it('skips cards with neither a name nor an email', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nNOTE:orphan\r\nEND:VCARD';
expect(parseVCard(vcf)).toHaveLength(0);
});
it('strips mailto:/tel: URI schemes', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:U\r\nEMAIL:mailto:u@x.com\r\nTEL:tel:+15550111\r\nEND:VCARD';
const [card] = parseVCard(vcf);
expect(Object.values(card.emails ?? {})[0].address).toBe('u@x.com');
expect(Object.values(card.phones ?? {})[0].number).toBe('+15550111');
});
});
describe('generateVCard', () => {
it('round-trips name and email through parse → generate → parse', () => {
const original = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nN:Doe;Jane;;;\r\nEMAIL;TYPE=WORK:jane@example.com\r\nEND:VCARD';
const [parsed] = parseVCard(original);
const generated = generateVCard([parsed]);
expect(generated).toContain('FN:Jane Doe');
expect(generated).toContain('EMAIL;TYPE=WORK:jane@example.com');
const [reparsed] = parseVCard(generated);
expect(Object.values(reparsed.emails ?? {})[0].address).toBe('jane@example.com');
});
it('contactToVCard emits a single BEGIN/END block', () => {
const card: ContactCard = {
id: 'c1',
addressBookIds: {},
name: { full: 'Solo', components: [{ kind: 'given', value: 'Solo' }] },
emails: { e0: { address: 'solo@x.com' } },
};
const out = contactToVCard(card);
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
expect(out.match(/END:VCARD/g)).toHaveLength(1);
});
});
describe('detectDuplicates', () => {
const existing: ContactCard[] = [
{ id: 'x1', addressBookIds: {}, emails: { e0: { address: 'dupe@x.com' } } },
];
it('flags an incoming contact that shares an email (case-insensitive)', () => {
const incoming: ContactCard[] = [
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'fresh@x.com' } } },
{ id: 'i1', addressBookIds: {}, emails: { e0: { address: 'DUPE@x.com' } } },
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.has(0)).toBe(false);
expect(dupes.get(1)).toBe('x1');
});
it('returns an empty map when nothing overlaps', () => {
const incoming: ContactCard[] = [
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'new@x.com' } } },
];
expect(detectDuplicates(existing, incoming).size).toBe(0);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { eachYearOfInterval, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent, ContactCard, AnniversaryDate } from '../api/types';
import { getContactDisplayName } from './contact-utils';
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow
// A read-only virtual calendar holding generated birthday events.
export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name,
color: BIRTHDAY_CALENDAR_COLOR,
sortOrder: 999,
isSubscribed: true,
isVisible: true,
isDefault: false,
myRights: {
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayWrite: false,
mayDelete: false,
mayRSVP: false,
},
};
}
function parseBirthdayDate(date: AnniversaryDate): { month: number; day: number; year?: number } | null {
if (typeof date === 'string') {
if (date.startsWith('--')) {
const m = date.match(/^--(\d{2})-(\d{2})$/);
return m ? { month: parseInt(m[1], 10), day: parseInt(m[2], 10) } : null;
}
const parsed = parseISO(date);
if (!isNaN(parsed.getTime())) {
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
}
return null;
}
if ('utc' in date && date['@type'] === 'Timestamp') {
const parsed = parseISO(date.utc);
if (!isNaN(parsed.getTime())) {
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
}
return null;
}
// PartialDate
if (date.month && date.day) {
return { month: date.month, day: date.day, year: date.year || undefined };
}
return null;
}
// Generate virtual all-day birthday events for the given range from contacts
// that carry a `birth` anniversary.
export function generateBirthdayEvents(
contacts: ContactCard[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return events;
const years = eachYearOfInterval({ start, end });
const endYear = end.getFullYear();
if (!years.some((y) => y.getFullYear() === endYear)) {
years.push(new Date(endYear, 0, 1));
}
for (const contact of contacts) {
if (!contact.anniversaries) continue;
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
if (anniversary.kind !== 'birth') continue;
const parsed = parseBirthdayDate(anniversary.date);
if (!parsed) continue;
const displayName = getContactDisplayName(contact);
if (!displayName) continue;
for (const yearDate of years) {
const year = yearDate.getFullYear();
// Feb 29 in a non-leap year: clamp to Feb 28.
let occMonth = parsed.month;
let occDay = parsed.day;
const occurrence = new Date(year, occMonth - 1, occDay);
if (occurrence.getMonth() !== occMonth - 1) {
occurrence.setDate(0);
occMonth = occurrence.getMonth() + 1;
occDay = occurrence.getDate();
}
if (occurrence < start || occurrence > end) continue;
const mm = String(occMonth).padStart(2, '0');
const dd = String(occDay).padStart(2, '0');
const age = parsed.year ? year - parsed.year : undefined;
const ageText = age && age > 0 ? ` (${age})` : '';
events.push({
id: `birthday-${contact.id}-${key}-${year}`,
'@type': 'Event',
uid: `birthday-${contact.id}-${key}`,
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
title: `🎂 ${displayName}${ageText}`,
start: `${year}-${mm}-${dd}T00:00:00`,
duration: 'P1D',
showWithoutTime: true,
status: 'confirmed',
freeBusyStatus: 'free',
color: BIRTHDAY_CALENDAR_COLOR,
});
}
}
}
return events;
}
+103
View File
@@ -0,0 +1,103 @@
import type { Alert, CalendarEvent } from '../api/types';
// A reminder, expressed as "minutes before the event start". 0 means "at the
// time of the event". Negative values (reminder *after* start) are preserved
// on round-trip but the picker only offers non-negative presets.
export interface Reminder {
/** Minutes before start. 0 = at time of event. */
minutesBefore: number;
}
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
// Parse an ISO 8601 duration offset ("-PT15M", "-P1D", "PT0S") to minutes.
// Positive return value = minutes *before* the anchor (JSCalendar offsets for
// reminders are negative, e.g. "-PT15M" fires 15 min before start).
export function offsetToMinutesBefore(offset: string | undefined): number | null {
if (!offset) return null;
const m = DURATION_RE.exec(offset);
if (!m) return null;
const negative = m[1] === '-';
const weeks = parseInt(m[2] || '0', 10);
const days = parseInt(m[3] || '0', 10);
const hours = parseInt(m[4] || '0', 10);
const minutes = parseInt(m[5] || '0', 10);
const seconds = parseInt(m[6] || '0', 10);
const totalMinutes = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + minutes + Math.round(seconds / 60);
// A negative offset (fires before start) is a positive "minutesBefore".
// `|| 0` collapses -0 to 0 so callers get a clean "at time of event".
return (negative ? totalMinutes : -totalMinutes) || 0;
}
export function minutesBeforeToOffset(minutesBefore: number): string {
if (minutesBefore === 0) return 'PT0S';
const sign = minutesBefore > 0 ? '-' : '';
let mins = Math.abs(minutesBefore);
const days = Math.floor(mins / (24 * 60));
mins -= days * 24 * 60;
const hours = Math.floor(mins / 60);
mins -= hours * 60;
let out = 'P';
if (days > 0) out += `${days}D`;
if (hours > 0 || mins > 0) {
out += 'T';
if (hours > 0) out += `${hours}H`;
if (mins > 0) out += `${mins}M`;
}
if (out === 'P') out = 'PT0M';
return `${sign}${out}`;
}
// Pull the list of reminders out of an event's `alerts` map. Only display-style
// offset alerts relative to start are surfaced in the editor; anything exotic is
// dropped from the UI list (but the event keeps it until the user saves).
export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
if (!alerts) return [];
const out: Reminder[] = [];
for (const alert of Object.values(alerts)) {
const trigger = alert?.trigger;
if (!trigger) continue;
if (trigger.offset === undefined) continue; // skip AbsoluteTriggers in the picker
const minutesBefore = offsetToMinutesBefore(trigger.offset);
if (minutesBefore === null) continue;
out.push({ minutesBefore });
}
// De-dupe + sort soonest-to-event last (largest lead time first).
const seen = new Set<number>();
return out
.filter((r) => (seen.has(r.minutesBefore) ? false : (seen.add(r.minutesBefore), true)))
.sort((a, b) => b.minutesBefore - a.minutesBefore);
}
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
if (reminders.length === 0) return undefined;
const alerts: Record<string, Alert> = {};
reminders.forEach((r, i) => {
alerts[`reminder-${i + 1}`] = {
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
action: 'display',
};
});
return alerts;
}
export function formatReminder(minutesBefore: number): string {
if (minutesBefore === 0) return 'At time of event';
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
if (minutesBefore < 60 * 24) {
const h = minutesBefore / 60;
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
}
if (minutesBefore < 60 * 24 * 7) {
const d = minutesBefore / (60 * 24);
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
}
const w = minutesBefore / (60 * 24 * 7);
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
}
// Presets offered by the reminder picker (minutes before start).
export const REMINDER_PRESETS: number[] = [
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,
];
+148
View File
@@ -0,0 +1,148 @@
import type { CalendarEvent, Participant, Email, Attachment } from '../api/types';
// ─── Address helpers ─────────────────────────────────────
export function normalizeEmail(value?: string | null): string | null {
if (!value) return null;
const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase();
return normalized || null;
}
export function getParticipantEmail(p: Participant): string | null {
const direct = normalizeEmail(p.email);
if (direct) return direct;
if (p.calendarAddress) {
const addr = normalizeEmail(p.calendarAddress);
if (addr) return addr;
}
if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) {
const n = normalizeEmail(addr);
if (n) return n;
}
}
return null;
}
export function isOrganizerParticipant(p: Participant): boolean {
return Boolean(p.roles?.owner || p.roles?.chair);
}
export function getOrganizerEmail(event: Partial<CalendarEvent>): string | null {
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (isOrganizerParticipant(p)) {
const e = getParticipantEmail(p);
if (e) return e;
}
}
}
if (event.organizerCalendarAddress) return normalizeEmail(event.organizerCalendarAddress);
return null;
}
export function getOrganizerName(event: Partial<CalendarEvent>): string | null {
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (isOrganizerParticipant(p)) return p.name || getParticipantEmail(p);
}
if (event.organizerCalendarAddress) {
for (const p of Object.values(event.participants)) {
if (p.calendarAddress === event.organizerCalendarAddress) return p.name || getParticipantEmail(p);
}
}
}
return getOrganizerEmail(event);
}
// Find the participant entry that matches one of the given user emails.
export function findParticipantByEmail(
event: Partial<CalendarEvent>,
emails: string[],
): { id: string; participant: Participant } | null {
if (!event.participants || emails.length === 0) return null;
const wanted = new Set(emails.map((e) => e.toLowerCase()).filter(Boolean));
for (const [id, p] of Object.entries(event.participants)) {
const e = getParticipantEmail(p);
if (e && wanted.has(e)) return { id, participant: p };
}
return null;
}
// `replyTo` to attach to an RSVP so the server knows where to deliver the iTIP
// reply (Stalwart exposes the organizer as organizerCalendarAddress).
export function buildReplyTo(event: Partial<CalendarEvent>): Record<string, string> | null {
if (event.replyTo) return event.replyTo;
if (event.organizerCalendarAddress) {
const addr = event.organizerCalendarAddress.startsWith('mailto:')
? event.organizerCalendarAddress
: `mailto:${event.organizerCalendarAddress}`;
return { imip: addr };
}
return null;
}
// ─── iMIP method detection ───────────────────────────────
export type InvitationMethod =
| 'publish' | 'request' | 'reply' | 'add'
| 'cancel' | 'refresh' | 'counter' | 'declinecounter' | 'unknown';
const KNOWN_METHODS = new Set<InvitationMethod>([
'publish', 'request', 'reply', 'add', 'cancel', 'refresh', 'counter', 'declinecounter',
]);
function normalizeMethod(value?: string | null): InvitationMethod {
if (!value) return 'unknown';
const v = value.trim().toLowerCase();
return KNOWN_METHODS.has(v as InvitationMethod) ? (v as InvitationMethod) : 'unknown';
}
// JMAP strips Content-Type params (RFC 8621), so `text/calendar; method=REQUEST`
// arrives as `text/calendar`. The raw ICS METHOD line is the reliable source.
export function extractMethodFromRawIcs(rawText: string): InvitationMethod {
const m = rawText.match(/^METHOD:(\S+)/m);
return m ? normalizeMethod(m[1]) : 'unknown';
}
function looksLikeReply(event: Partial<CalendarEvent>): boolean {
if (!event.participants) return false;
return Object.values(event.participants).some(
(p) =>
p.roles?.attendee &&
!isOrganizerParticipant(p) &&
(p.participationStatus !== 'needs-action' || !!p.participationComment),
);
}
export function inferInvitationMethod(event: Partial<CalendarEvent>): InvitationMethod {
if (event.status === 'cancelled') return 'cancel';
if (looksLikeReply(event)) return 'reply';
if (event.participants && Object.keys(event.participants).length > 0) {
if (Object.values(event.participants).some(isOrganizerParticipant) || event.organizerCalendarAddress) {
return 'request';
}
}
return 'unknown';
}
// ─── Calendar attachment detection ───────────────────────
const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']);
function isCalendarType(type?: string | null): boolean {
if (!type) return false;
const base = type.split(';')[0].trim().toLowerCase();
return CAL_MIME.has(base);
}
export function findCalendarAttachment(email: Pick<Email, 'attachments'>): Attachment | null {
if (!email.attachments) return null;
for (const att of email.attachments) {
const name = att.name?.toLowerCase() || '';
if (isCalendarType(att.type) || name.endsWith('.ics') || name.endsWith('.ical')) {
return att;
}
}
return null;
}
+166
View File
@@ -0,0 +1,166 @@
import * as WebBrowser from 'expo-web-browser';
import { secureFetch } from './client-cert';
// Webmail-mediated login. The app opens the webmail's normal login page with
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
// uses its existing password and OAuth flows, and once the user is signed
// in, redirects back to the app's custom scheme with credentials packed into
// the URL fragment. Fragments aren't sent to the server, so password and
// token material don't appear in HTTP access logs along the way.
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
export interface OAuthTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: number; // epoch ms
tokenEndpoint: string;
clientId: string;
}
export type HandoffResult =
| {
flow: 'password';
serverUrl: string;
username: string;
password: string;
}
| {
flow: 'oauth';
serverUrl: string;
tokens: OAuthTokens;
};
export class HandoffError extends Error {}
export class HandoffCancelledError extends HandoffError {
constructor() {
super('Sign-in cancelled');
}
}
function randomState(): string {
const bytes = new Uint8Array(16);
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
function buildHandoffUrl(webmailUrl: string, state: string): string {
const base = webmailUrl.replace(/\/+$/, '');
const params = new URLSearchParams({
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
mobile_state: state,
});
return `${base}/login?${params.toString()}`;
}
function parseFragment(url: string): URLSearchParams {
const hashIdx = url.indexOf('#');
if (hashIdx === -1) return new URLSearchParams();
return new URLSearchParams(url.slice(hashIdx + 1));
}
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResult> {
const state = randomState();
const handoffUrl = buildHandoffUrl(webmailUrl, state);
const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI);
if (result.type === 'cancel' || result.type === 'dismiss') {
throw new HandoffCancelledError();
}
if (result.type !== 'success' || !result.url) {
throw new HandoffError(`Sign-in failed: ${result.type}`);
}
const params = parseFragment(result.url);
const err = params.get('error');
if (err) throw new HandoffError(err);
// CSRF guard: the state we generated must round-trip through the webmail
// unchanged. A mismatch means the redirect didn't come from the flow we
// started, and the rest of the fragment shouldn't be trusted.
if (params.get('state') !== state) {
throw new HandoffError('State mismatch');
}
const flow = params.get('flow');
const serverUrl = params.get('server_url');
if (!serverUrl) throw new HandoffError('Sign-in response missing server URL');
if (flow === 'password') {
const username = params.get('username');
const password = params.get('password');
if (!username || !password) {
throw new HandoffError('Sign-in response missing credentials');
}
return { flow: 'password', serverUrl, username, password };
}
if (flow === 'oauth') {
const accessToken = params.get('access_token');
const tokenEndpoint = params.get('token_endpoint');
const clientId = params.get('client_id');
if (!accessToken || !tokenEndpoint || !clientId) {
throw new HandoffError('Sign-in response missing OAuth tokens');
}
const refreshToken = params.get('refresh_token') ?? undefined;
const expiresIn = params.get('expires_in');
return {
flow: 'oauth',
serverUrl,
tokens: {
accessToken,
refreshToken,
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
tokenEndpoint,
clientId,
},
};
}
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
}
// OAuth refresh — exchanges the refresh token at the original token endpoint
// for a new access token. Returns the updated bundle so the caller can
// persist it.
export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAuthTokens> {
if (!tokens.refreshToken) {
throw new HandoffError('No refresh token available');
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
}
WebBrowser.maybeCompleteAuthSession();
+20
View File
@@ -0,0 +1,20 @@
// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when
// available (Hermes/React Native 0.81 exposes it), falling back to a
// Math.random implementation. These IDs are only used as temporary client-side
// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so
// the Math.random fallback's weaker entropy is acceptable.
export function generateUUID(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
if (c?.randomUUID) {
try {
return c.randomUUID();
} catch {
// fall through to the manual implementation
}
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+1203 -116
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -23,6 +23,8 @@ export type RootStackParamList = {
ContactForm: { contactId?: string; addressBookId?: string; asGroup?: boolean };
GroupDetail: { groupId: string };
AddAccount: undefined;
Scheduled: undefined;
UnifiedInbox: undefined;
};
export type MainTabsParamList = {
+95 -4
View File
@@ -16,6 +16,7 @@ import {
CalendarDays,
LayoutGrid,
List as ListIcon,
ListChecks,
Menu,
} from 'lucide-react-native';
import {
@@ -47,11 +48,17 @@ import {
type RecurrenceEditScope,
} from '../components/calendar/RecurrenceScopeDialog';
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
import { TasksSheet } from '../components/calendar/TasksSheet';
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
type EventDayIndex,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { useContactsStore } from '../stores/contacts-store';
import type { Calendar, CalendarEvent } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
@@ -108,6 +115,9 @@ export default function CalendarScreen() {
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const contacts = useContactsStore((s) => s.contacts);
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
@@ -125,6 +135,9 @@ export default function CalendarScreen() {
const [modalVisible, setModalVisible] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
const [sidebarVisible, setSidebarVisible] = React.useState(false);
const [tasksVisible, setTasksVisible] = React.useState(false);
const [importVisible, setImportVisible] = React.useState(false);
const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const hydrate = useCalendarStore((s) => s.hydrate);
@@ -134,12 +147,36 @@ export default function CalendarScreen() {
const createEvent = useCalendarStore((s) => s.createEvent);
const updateEvent = useCalendarStore((s) => s.updateEvent);
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const importEvents = useCalendarStore((s) => s.importEvents);
const tasks = useCalendarStore((s) => s.tasks);
const createTask = useCalendarStore((s) => s.createTask);
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
const deleteTask = useCalendarStore((s) => s.deleteTask);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const allCalendars = useCalendarStore((s) => s.calendars);
const storeCalendars = useCalendarStore((s) => s.calendars);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
const error = useCalendarStore((s) => s.error);
const allEvents = useCalendarStore((s) => s.events);
const storeEvents = useCalendarStore((s) => s.events);
// The birthday calendar is a client-side virtual calendar: its events are
// generated from contacts for the visible range and merged in alongside the
// server calendars. Toggling it on/off is instant (no refetch).
const birthdayEvents = React.useMemo(() => {
if (!showBirthdayCalendar) return [];
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString());
}, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]);
const allCalendars = React.useMemo(
() => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars),
[storeCalendars, showBirthdayCalendar],
);
const allEvents = React.useMemo(
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
[storeEvents, birthdayEvents],
);
const calendars = React.useMemo(
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
@@ -210,23 +247,30 @@ export default function CalendarScreen() {
setModalVisible(true);
}, []);
const isReadOnlyEvent = React.useCallback(
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
[],
);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect]);
}, [openEditDirect, isReadOnlyEvent]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent]);
}, [deleteEvent, isReadOnlyEvent]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
@@ -293,6 +337,15 @@ export default function CalendarScreen() {
</Text>
</View>
<View style={styles.headerActions}>
{enableCalendarTasks && (
<Pressable
style={styles.headerBtn}
onPress={() => setTasksVisible(true)}
hitSlop={6}
>
<ListChecks size={20} color={c.text} />
</Pressable>
)}
<View style={styles.viewToggle}>
{(['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,
);
}}
/>
<EventModal
@@ -422,6 +489,30 @@ export default function CalendarScreen() {
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
onClose={() => setSidebarVisible(false)}
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
/>
<TasksSheet
visible={tasksVisible}
tasks={tasks}
calendars={allCalendars}
onClose={() => setTasksVisible(false)}
onCreate={createTask}
onToggle={toggleTaskComplete}
onDelete={deleteTask}
/>
<ICalImportSheet
visible={importVisible}
calendars={storeCalendars}
onClose={() => setImportVisible(false)}
onImport={importEvents}
/>
<ICalSubscriptionSheet
visible={subscriptionsVisible}
onClose={() => setSubscriptionsVisible(false)}
/>
</SafeAreaView>
);
+239 -4
View File
@@ -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<string | null>(null);
const [sending, setSending] = React.useState(false);
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(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<Date>(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<Recipient[]>(() => {
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) {
>
<Paperclip size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setScheduleSheetOpen(true)}
style={styles.headerBtn}
hitSlop={8}
disabled={!canSend}
>
<Clock size={20} color={canSend ? c.text : c.textMuted} />
</Pressable>
<Button
variant="default"
size="sm"
onPress={() => { void onSend(); }}
onPress={onSend}
disabled={!canSend}
icon={
sending ? (
@@ -1083,6 +1202,93 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</View>
</Modal>
{/* Schedule send sheet */}
<Modal
visible={scheduleSheetOpen}
transparent
animationType="fade"
onRequestClose={() => setScheduleSheetOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
{!jmapClient.hasDelayedSend() && (
<Text style={styles.scheduleWarning}>
{t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.')}
</Text>
)}
{schedulePresets.map((preset) => (
<Pressable
key={preset.label}
style={styles.scheduleRow}
onPress={() => onScheduleConfirm(preset.date)}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.scheduleRowLabel}>{preset.label}</Text>
<Text style={styles.scheduleRowTime}>
{preset.date.toLocaleString(undefined, {
weekday: 'short', hour: 'numeric', minute: '2-digit',
})}
</Text>
</Pressable>
))}
<Pressable style={styles.scheduleRow} onPress={startCustomPicker}>
<Check size={16} color={c.textSecondary} />
<Text style={styles.scheduleRowLabel}>
{t('email_composer.schedule_custom', 'Pick date & time…')}
</Text>
</Pressable>
<Pressable
style={styles.scheduleCancel}
onPress={() => setScheduleSheetOpen(false)}
>
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
{customStage !== null && Platform.OS === 'ios' && (
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<DateTimePicker
value={customDraftRef.current}
mode="datetime"
display="spinner"
minimumDate={new Date()}
onChange={onCustomPickerChange}
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={() => setCustomStage(null)}>
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable
style={styles.modalConfirm}
onPress={() => { setCustomStage(null); onScheduleConfirm(customDraftRef.current); }}
>
<Text style={styles.modalConfirmText}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
)}
{customStage !== null && Platform.OS !== 'ios' && (
<DateTimePicker
value={customDraftRef.current}
mode={customStage === 'time' ? 'time' : 'date'}
display="default"
minimumDate={customStage === 'date' ? new Date() : undefined}
onChange={onCustomPickerChange}
/>
)}
</SafeAreaView>
);
}
@@ -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,
},
});
}
+25 -1
View File
@@ -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: <Copy size={16} color={c.text} />,
label: 'Duplicate',
onPress: () => { void doDuplicate(); },
},
addressBooks.length > 0 && {
icon: <FolderInput size={16} color={c.text} />,
label: 'Move to address book',
onPress: () => setMoveOpen(true),
},
{
icon: <Share2 size={16} color={c.text} />,
label: 'Export vCard',
@@ -614,6 +631,13 @@ export default function ContactDetailScreen() {
items={moreItems}
onClose={() => setMoreOpen(false)}
/>
<AddressBookPickerSheet
visible={moveOpen}
onClose={() => setMoveOpen(false)}
currentBookId={bookIds[0] ?? null}
onPick={(id) => { void doMove(id); }}
/>
</SafeAreaView>
);
}
+106 -2
View File
@@ -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<Set<string>>(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<string | null>(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() {
</Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text>
<View style={styles.headerActions}>
{addressBooks.length > 0 && (
<Pressable
onPress={() => setMoveSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<FolderInput size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => setTagSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<Tag size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setConfirmBulkDelete(true)}
style={styles.headerIconBtn}
@@ -258,6 +326,13 @@ export default function ContactsScreen() {
>
<Search size={20} color={c.text} />
</Pressable>
<Pressable
onPress={openImport}
style={styles.headerIconBtn}
hitSlop={8}
>
<Upload size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => navigation.navigate('ContactForm', {})}
style={styles.addBtn}
@@ -380,6 +455,35 @@ export default function ContactsScreen() {
onConfirm={doBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
<AddressBookPickerSheet
visible={moveSheetOpen}
onClose={() => setMoveSheetOpen(false)}
onPick={(id) => { void handleBulkMove(id); }}
/>
<TagAssignSheet
visible={tagSheetOpen}
onClose={() => setTagSheetOpen(false)}
onPick={(kw) => { void handleBulkTag(kw); }}
/>
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
targetBookId={importTargetBookId}
targetBookName={importTargetName}
onChangeTarget={addressBooks.length > 1 ? () => setImportTargetOpen(true) : undefined}
onImported={() => { void fetchContacts(); }}
/>
<AddressBookPickerSheet
visible={importTargetOpen}
onClose={() => setImportTargetOpen(false)}
currentBookId={importTargetBookId}
title="Import into address book"
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
/>
</SafeAreaView>
);
}
+74 -2
View File
@@ -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<string | null>(null);
// Batch (multi-select) sheets.
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(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
<MailOpen size={20} color={c.text} />
)}
</Pressable>
<Pressable
onPress={() => setTagSheetOpen(true)}
style={styles.headerButton}
hitSlop={6}
>
<Tag size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setBatchMoveOpen(true)}
style={styles.headerButton}
hitSlop={6}
>
<FolderInput size={20} color={c.text} />
</Pressable>
{canArchiveSelection && (
<Pressable
onPress={() => { void handleBulkArchive(); }}
style={styles.headerButton}
hitSlop={6}
>
<Archive size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => { void handleBulkDelete(); }}
style={styles.headerButton}
@@ -891,6 +947,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}}
/>
<MoveSheet
visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
onPick={handleBatchMovePick}
/>
<TagSheet
visible={tagSheetOpen}
onClose={() => setTagSheetOpen(false)}
keywords={keywordDefs}
selectedEmails={selectedEmails}
onToggle={handleBatchTagToggle}
/>
<UndoSnackbar />
</SafeAreaView>
);
+4
View File
@@ -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) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView email={email} senderEmail={from?.email} />
+31 -3
View File
@@ -20,6 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
@@ -30,6 +31,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const [showPassword, setShowPassword] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
const handleLogin = async () => {
if (!canSubmit) {
@@ -44,6 +46,25 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
}
};
const handleWebmailHandoff = async () => {
if (!canHandoff) return;
try {
const before = useAuthStore.getState().isAuthenticated;
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
// loginViaWebmail swallows the "user cancelled the browser" case
// silently — only fire the navigation hook when authentication
// actually completed.
const after = useAuthStore.getState().isAuthenticated;
if (after && !before) {
onLogin?.();
} else if (isAddMode && after) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
@@ -149,10 +170,17 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
<View style={styles.dividerLine} />
</View>
{/* OAuth buttons */}
{/* Webmail-mediated login */}
<View style={styles.oauthRow}>
<Button variant="outline" size="md" disabled>
SSO Provider
<Button
variant="outline"
size="md"
onPress={() => {
void handleWebmailHandoff();
}}
disabled={!canHandoff || isLoading}
>
Sign in via webmail
</Button>
</View>
</View>
+196
View File
@@ -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<RootStackParamList, 'Scheduled'>;
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<ScheduledEmail[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [cancelling, setCancelling] = React.useState<string | null>(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 }) => (
<View style={styles.row}>
<View style={styles.rowMain}>
<View style={styles.sendAtRow}>
<Clock size={13} color={c.primary} />
<Text style={styles.sendAt}>{new Date(item.sendAt).toLocaleString()}</Text>
</View>
<Text style={styles.subject} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
<Text style={styles.recipient} numberOfLines={1}>
To: {recipientLabel(item)}
</Text>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null}
</View>
<Pressable
onPress={() => onCancel(item)}
style={styles.cancelBtn}
hitSlop={8}
disabled={cancelling === item.emailSubmissionId}
>
{cancelling === item.emailSubmissionId ? (
<ActivityIndicator size="small" color={c.error} />
) : (
<X size={18} color={c.error} />
)}
</Pressable>
</View>
);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>Scheduled</Text>
<View style={styles.headerBtn} />
</View>
{loading ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Pressable onPress={() => { void load(); }}>
<Text style={styles.retry}>Retry</Text>
</Pressable>
</View>
) : items.length === 0 ? (
<View style={styles.center}>
<Clock size={40} color={c.textMuted} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>No scheduled messages</Text>
<Text style={styles.emptyHint}>
Use the clock icon in the composer to send a message later.
</Text>
</View>
) : (
<FlatList
data={items}
keyExtractor={(i) => i.emailSubmissionId}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.listContent}
refreshing={loading}
onRefresh={() => { void load(); }}
/>
)}
</SafeAreaView>
);
}
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,
},
});
}
+234
View File
@@ -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<RootStackParamList, 'UnifiedInbox'>;
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<UnifiedEmail[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
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 (
<Pressable
onPress={() => onOpen(item)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={styles.avatarWrap}>
<SenderAvatar
name={senderName(item)}
email={item.from?.[0]?.email}
size={componentSizes.avatarMd}
/>
{acc && <View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />}
</View>
<View style={styles.content}>
<View style={styles.line}>
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
{senderName(item)}
</Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.time}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
<View style={styles.line}>
{acc && (
<Text style={styles.account} numberOfLines={1}>
{acc.email || acc.username}
</Text>
)}
</View>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null}
</View>
</Pressable>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>All inboxes</Text>
<View style={styles.headerBtn}>
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
</View>
</View>
{errorCount > 0 && (
<View style={styles.errorBanner}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.errorBannerText} numberOfLines={1}>
{errorCount === 1
? '1 account could not be loaded'
: `${errorCount} accounts could not be loaded`}
</Text>
</View>
)}
{loading && emails.length === 0 ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
<Text style={styles.loadingText}>Loading all inboxes…</Text>
</View>
) : emails.length === 0 ? (
<View style={styles.center}>
<Text style={styles.loadingText}>No mail across your accounts</Text>
</View>
) : (
<FlatList
data={emails}
keyExtractor={(item) => `${item.sourceAccountId}:${item.id}`}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
refreshing={loading}
onRefresh={() => { void load(); }}
/>
)}
</SafeAreaView>
);
}
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 },
});
}
+3 -1
View File
@@ -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);
});
});
+67
View File
@@ -6,6 +6,7 @@ import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { generateAccountId } from '../lib/account-utils';
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
@@ -38,6 +39,7 @@ export interface AuthState {
client: typeof jmapClient | null;
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -167,6 +169,71 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginViaWebmail: async (webmailUrl, opts) => {
set({ isLoading: true, error: null });
let result;
try {
result = await runWebmailHandoff(webmailUrl);
} catch (err) {
if (err instanceof HandoffCancelledError) {
// User closed the browser tab — quiet exit, no error banner.
set({ isLoading: false, error: null });
return;
}
const message = err instanceof Error ? err.message : 'Sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
if (result.flow === 'password') {
// Hand the credentials to the existing password login path so account
// registration + feature-store wiring all behave identically to a
// manual sign-in.
await get().login(result.serverUrl, result.username, result.password, opts);
return;
}
// OAuth — the webmail did the dance against Stalwart and handed us a
// token bundle. Bootstrap the JMAP session with Bearer auth and let
// ensure/forceRefreshToken keep it alive going forward.
try {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'OAuth sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
},
logout: async () => {
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
+122 -6
View File
@@ -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<CalendarEvent> | 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<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
updateEvent: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
deleteEvent: (id: string) => Promise<void>;
// Invitations / scheduling
rsvpEvent: (
eventId: string,
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
) => Promise<void>;
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
createCalendar: (name: string, color?: string) => Promise<Calendar>;
// Tasks
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
updateTask: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
toggleTaskComplete: (id: string) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
toggleCalendarVisibility: (id: string) => void;
setCalendarHidden: (id: string, hidden: boolean) => void;
reset: () => void;
@@ -66,6 +90,7 @@ export const useCalendarStore = create<CalendarState>()(
(set, get) => ({
calendars: [],
events: [],
tasks: [],
hiddenCalendarIds: [],
loadedRange: null,
loading: false,
@@ -108,14 +133,16 @@ export const useCalendarStore = create<CalendarState>()(
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<CalendarState>()(
},
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<CalendarState>()(
// 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<CalendarState>()(
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<CalendarEvent> = 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<CalendarState>()(
reset: () => set({
calendars: [],
events: [],
tasks: [],
loadedRange: null,
loading: false,
error: null,
@@ -225,6 +340,7 @@ export const useCalendarStore = create<CalendarState>()(
partialize: (state) => ({
calendars: state.calendars,
events: state.events,
tasks: state.tasks,
loadedRange: state.loadedRange,
}),
},
+168
View File
@@ -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<string, boolean>;
addSubscription: (input: { name: string; url: string; color?: string }) => Promise<CalendarSubscription>;
removeSubscription: (id: string) => Promise<void>;
syncSubscription: (id: string) => Promise<void>;
syncAll: () => Promise<void>;
}
// 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<Partial<CalendarEvent>[]> {
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<void> {
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<SubscriptionsState>()(
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 }),
},
),
);
+157
View File
@@ -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<void>;
fetchAddressBooks: () => Promise<void>;
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
@@ -45,11 +60,25 @@ export interface ContactsState {
updateContact: (id: string, changes: Partial<ContactCard>) => Promise<void>;
deleteContact: (id: string) => Promise<void>;
bulkDelete: (ids: string[]) => Promise<void>;
importContacts: (contacts: Partial<ContactCard>[], addressBookId: string) => Promise<number>;
addContactKeyword: (id: string, keyword: string) => Promise<void>;
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
// Address book management
createAddressBook: (name: string) => Promise<AddressBook>;
renameAddressBook: (id: string, name: string) => Promise<void>;
deleteAddressBook: (id: string) => Promise<void>;
// 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<void>;
addToTrustedSendersBook: (email: string) => Promise<void>;
removeFromTrustedSendersBook: (email: string) => Promise<void>;
isTrustedAddressBookSender: (email: string) => boolean;
setSelectedCategory: (category: ContactCategory) => void;
reset: () => void;
}
@@ -70,6 +99,11 @@ export const useContactsStore = create<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
selectedCategory: { type: 'all' },
loading: false,
error: null,
trustedSendersBookId: null,
trustedSenderEmails: [],
trustedSendersLoaded: false,
trustedSendersLoading: false,
}),
}),
{
+135
View File
@@ -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<void>;
archiveEmail: (emailId: string) => Promise<void>;
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
// ── Batch (multi-select) actions ──────────────────────────────
archiveEmailsBatch: (emailIds: string[]) => Promise<void>;
moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise<void>;
deleteEmailsBatch: (emailIds: string[], trashMailboxId: string, currentMailboxId: string) => Promise<void>;
setKeywordForEmails: (emailIds: string[], token: string, on: boolean) => Promise<void>;
undoLast: () => Promise<void>;
clearUndo: () => void;
searchEmails: (query: string) => Promise<Email[]>;
@@ -957,6 +965,133 @@ export const useEmailStore = create<EmailState>()(
}
},
// ── 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;
+4
View File
@@ -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',
+8
View File
@@ -64,3 +64,11 @@ vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn(async () => null),
deleteItemAsync: vi.fn(async () => undefined),
}));
// expo-web-browser transitively pulls in expo-modules-core, which evaluates
// RN-only globals at module load. The unit tests don't exercise the real
// browser handoff flow, so stubbing the surface is enough.
vi.mock('expo-web-browser', () => ({
openAuthSessionAsync: vi.fn(async () => ({ type: 'cancel' as const })),
maybeCompleteAuthSession: vi.fn(),
}));