commit 11b9b86acd97e6f28cf77af45968e1e6a7b8194b Author: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon Mar 30 00:49:22 2026 +0200 Initial commit: React Native JMAP webmail app diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d914c328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/App.tsx b/App.tsx new file mode 100644 index 00000000..ddeb7e37 --- /dev/null +++ b/App.tsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { NavigationContainer } from '@react-navigation/native'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack'; +import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native'; + +import { startPushUpdates } from './src/api/push'; +import type { MainTabsParamList, RootStackParamList } from './src/navigation/types'; +import ComposeScreen from './src/screens/ComposeScreen'; +import EmailThreadScreen from './src/screens/EmailThreadScreen'; +import LoginScreen from './src/screens/LoginScreen'; +import EmailListScreen from './src/screens/EmailListScreen'; +import FilesScreen from './src/screens/FilesScreen'; +import CalendarScreen from './src/screens/CalendarScreenNew'; +import ContactsScreen from './src/screens/ContactsScreenNew'; +import SettingsScreen from './src/screens/SettingsScreenNew2'; +import { useAuthStore } from './src/stores/auth-store'; +import { useCalendarStore } from './src/stores/calendar-store'; +import { useContactsStore } from './src/stores/contacts-store'; +import { useEmailStore } from './src/stores/email-store'; +import { colors, spacing, typography } from './src/theme/tokens'; + +const Stack = createNativeStackNavigator(); +const Tab = createBottomTabNavigator(); + +function LoadingScreen({ message }: { message: string }) { + return ( + + + {message} + + ); +} + +function MainTabsNavigator({ navigation }: NativeStackScreenProps) { + const mailboxes = useEmailStore((state) => state.mailboxes); + const logout = useAuthStore((state) => state.logout); + const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0; + + return ( + + , + tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined, + tabBarBadgeStyle: { + backgroundColor: colors.error, + color: colors.primaryForeground, + fontSize: 10, + fontWeight: '700', + minWidth: 16, + height: 16, + lineHeight: 16, + borderRadius: 8, + top: -2, + right: -6, + }, + }} + > + {() => ( + navigation.navigate('Compose')} + onEmailPress={(email) => { + navigation.navigate('EmailThread', { + emailId: email.id, + threadId: email.threadId, + subject: email.subject, + }); + }} + /> + )} + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + > + {() => } + + + ); +} + +export default function App() { + const hasRestoredSession = useAuthStore((state) => state.hasRestoredSession); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const client = useAuthStore((state) => state.client); + const restoreSession = useAuthStore((state) => state.restoreSession); + + React.useEffect(() => { + if (!hasRestoredSession) { + void restoreSession(); + } + }, [hasRestoredSession, restoreSession]); + + React.useEffect(() => { + if (!isAuthenticated || !client) { + return; + } + + let mounted = true; + let cleanup: (() => void) | null = null; + + void (async () => { + try { + const stopPushUpdates = await startPushUpdates(client, { + onStateChange: async (change) => { + await Promise.all([ + useEmailStore.getState().handleStateChange(change), + useContactsStore.getState().handleStateChange(change), + useCalendarStore.getState().handleStateChange(change), + ]); + }, + onError: (error) => { + console.warn(error.message); + }, + }); + + if (!mounted) { + stopPushUpdates(); + return; + } + + cleanup = stopPushUpdates; + } catch (error) { + console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates'); + } + })(); + + return () => { + mounted = false; + cleanup?.(); + }; + }, [client, isAuthenticated]); + + if (!hasRestoredSession) { + return ( + <> + + + + ); + } + + if (!isAuthenticated) { + return ( + <> + + + + ); + } + + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background, + gap: spacing.md, + }, + loadingText: { + ...typography.body, + color: colors.textSecondary, + }, +}); diff --git a/JMAP_IMPLEMENTATION_PROMPT.md b/JMAP_IMPLEMENTATION_PROMPT.md new file mode 100644 index 00000000..05379922 --- /dev/null +++ b/JMAP_IMPLEMENTATION_PROMPT.md @@ -0,0 +1,1201 @@ +# JMAP Implementation Prompt — Bulwark Mobile App + +> Use this prompt to implement a full JMAP (JSON Meta Application Protocol) client layer in the React Native mobile app at `repos/react-native/`, connecting it to the same Stalwart JMAP backend the webmail uses. + +--- + +## Goal + +Replace all mock/hardcoded data in the mobile app with a working JMAP client that authenticates against the Stalwart server, fetches real mailboxes, emails, contacts, calendars, and files, and receives real-time state change notifications. The implementation must mirror the patterns already established in the webmail's `lib/jmap/client.ts`. + +--- + +## Project Context + +- **Mobile app location:** `repos/react-native/` (Expo + React Native + TypeScript) +- **Webmail JMAP client reference:** `lib/jmap/client.ts` +- **Webmail auth reference:** `lib/auth/crypto.ts`, `lib/auth/session-cookie.ts` +- **Webmail store reference:** `stores/` +- **Backend:** Stalwart JMAP server (RFC 8620, RFC 8621) +- **Current state:** All screens use `MOCK_EMAILS`, `MOCK_CONTACTS`, etc. No networking exists. + +--- + +## Architecture + +Create the following directory structure inside `repos/react-native/src/`: + +``` +src/ +├── api/ +│ ├── jmap-client.ts # Core JMAP client (session, requests, auth) +│ ├── types.ts # TypeScript interfaces for all JMAP objects +│ ├── email.ts # Email/Mailbox/Thread operations +│ ├── contacts.ts # AddressBook/ContactCard operations +│ ├── calendar.ts # Calendar/CalendarEvent operations +│ ├── files.ts # FileNode operations (if supported) +│ ├── identity.ts # Identity operations +│ ├── submission.ts # EmailSubmission (send) +│ ├── blob.ts # Upload/download blob helpers +│ └── push.ts # EventSource (SSE) real-time updates +├── stores/ +│ ├── auth-store.ts # Login state, credentials, tokens +│ ├── email-store.ts # Mailboxes, emails, threads cache +│ ├── contacts-store.ts # Address books, contact cards cache +│ ├── calendar-store.ts # Calendars, events cache +│ └── settings-store.ts # User preferences, identities +``` + +--- + +## Phase 1 — JMAP Client Core (`api/jmap-client.ts`) + +### Session Discovery + +Implement the JMAP session bootstrap per RFC 8620 §2: + +``` +GET /.well-known/jmap +Authorization: Basic +``` + +Response shape: + +```typescript +interface JMAPSession { + apiUrl: string; + downloadUrl: string; // RFC 6570 template: {accountId}/{blobId}/{name}?type={type} + uploadUrl: string; // RFC 6570 template: {accountId}/ + eventSourceUrl: string; // SSE endpoint template: {types}/{closeafter}/{ping} + primaryAccounts: Record; // capability URN → accountId + accounts: Record< + string, + { name: string; isPersonal: boolean; isReadOnly: boolean } + >; + capabilities: Record; + state: string; +} +``` + +### Authentication + +Support two auth modes (match the webmail): + +1. **Basic Auth** — `Authorization: Basic ` +2. **Bearer Token** — `Authorization: Bearer ` (for OAuth flows) + +Store credentials securely using `expo-secure-store`: + +```typescript +import * as SecureStore from "expo-secure-store"; + +await SecureStore.setItemAsync( + "jmap_credentials", + JSON.stringify({ serverUrl, username, password }), +); +const creds = JSON.parse( + (await SecureStore.getItemAsync("jmap_credentials")) ?? "", +); +``` + +On 401 response: attempt token refresh (bearer) or prompt re-login (basic). + +### API Request Method + +```typescript +type JMAPMethodCall = [string, Record, string]; + +interface JMAPRequestBody { + using: string[]; + methodCalls: JMAPMethodCall[]; +} + +interface JMAPResponseBody { + methodResponses: Array<[string, Record, string]>; + sessionState?: string; +} + +async function request( + methodCalls: JMAPMethodCall[], + using?: string[], +): Promise { + // POST to session.apiUrl + // Headers: Content-Type: application/json, Authorization header + // Body: { using: [...capabilities], methodCalls } + // Handle 401 → re-auth, 429 → rate limit with Retry-After +} +``` + +### Capability URNs Used + +Include all of these in the `using` array as needed: + +| URN | Purpose | +| --------------------------------------- | ------------------------- | +| `urn:ietf:params:jmap:core` | Always required | +| `urn:ietf:params:jmap:mail` | Email, Mailbox, Thread | +| `urn:ietf:params:jmap:submission` | EmailSubmission (sending) | +| `urn:ietf:params:jmap:vacationresponse` | Vacation auto-reply | +| `urn:ietf:params:jmap:contacts` | AddressBook, ContactCard | +| `urn:ietf:params:jmap:calendars` | Calendar, CalendarEvent | +| `urn:ietf:params:jmap:sieve` | SieveScript (filters) | +| `urn:ietf:params:jmap:quota` | Quota info | + +Check `session.capabilities` before using any non-core capability. + +--- + +## Phase 2 — TypeScript Interfaces (`api/types.ts`) + +Define these interfaces matching both the JMAP RFCs and the webmail's existing types: + +```typescript +// ─── Email (RFC 8621) ──────────────────────────────────── +interface EmailAddress { + name?: string; + email: string; +} + +interface Email { + id: string; + threadId: string; + mailboxIds: Record; + keywords: Record; // $seen, $flagged, $draft, $answered, $forwarded, $label:* + size: number; + receivedAt: string; // UTC ISO 8601 + sentAt?: string; + from?: EmailAddress[]; + to?: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + replyTo?: EmailAddress[]; + subject?: string; + preview?: string; + hasAttachment: boolean; + textBody?: BodyPart[]; + htmlBody?: BodyPart[]; + bodyValues?: Record; + attachments?: Attachment[]; + blobId?: string; + bodyStructure?: BodyPart; +} + +interface BodyPart { + partId?: string; + blobId?: string; + type?: string; + name?: string; + disposition?: string; + size?: number; + cid?: string; +} + +interface Attachment { + blobId: string; + type: string; + name?: string; + size?: number; + disposition?: string; +} + +interface Mailbox { + id: string; + name: string; + parentId?: string | null; + role?: string | null; // inbox, drafts, sent, trash, junk, archive, etc. + sortOrder?: number; + totalEmails: number; + unreadEmails: number; + totalThreads: number; + unreadThreads: number; + myRights: MailboxRights; + isSubscribed?: boolean; +} + +interface MailboxRights { + mayReadItems: boolean; + mayAddItems: boolean; + mayRemoveItems: boolean; + maySetSeen: boolean; + maySetKeywords: boolean; + mayCreateChild: boolean; + mayRename: boolean; + mayDelete: boolean; + maySubmit: boolean; +} + +interface Thread { + id: string; + emailIds: string[]; +} + +// ─── Identity & Submission ─────────────────────────────── +interface Identity { + id: string; + name: string; + email: string; + replyTo?: EmailAddress[]; + bcc?: EmailAddress[]; + textSignature?: string; + htmlSignature?: string; + mayDelete: boolean; +} + +// ─── Contacts (RFC 9553 JSContact) ────────────────────── +interface ContactCard { + id: string; + uid?: string; + addressBookIds: Record; + kind?: string; + name?: { components: Array<{ kind: string; value: string }> }; + emails?: Record< + string, + { + address: string; + contexts?: Record; + label?: string; + pref?: number; + } + >; + phones?: Record< + string, + { + number: string; + features?: Record; + label?: string; + pref?: number; + } + >; + addresses?: Record< + string, + { components?: any[]; full?: string; countryCode?: string } + >; + organizations?: Record< + string, + { name: string; units?: Array<{ name: string }> } + >; + titles?: Record; + notes?: Record; + media?: Record; + keywords?: Record; +} + +interface AddressBook { + id: string; + name: string; + isDefault?: boolean; + sortOrder?: number; + myRights?: Record; +} + +// ─── Calendar (RFC 8984 JSCalendar) ───────────────────── +interface CalendarEvent { + id: string; + "@type"?: "Event" | "Task"; + uid: string; + calendarIds: Record; + title: string; + description?: string; + start: string; // Local date-time "2026-03-29T10:00:00" + duration?: string; // "PT1H", "P1D" + timeZone?: string; // IANA tz "America/New_York" + showWithoutTime?: boolean; // All-day event + utcStart?: string; + utcEnd?: string; + status?: string; + freeBusyStatus?: string; + participants?: Record; + recurrenceRules?: RecurrenceRule[]; + recurrenceOverrides?: Record>; + excludedRecurrenceRules?: RecurrenceRule[]; + alerts?: Record; + links?: Record; + created?: string; + updated?: string; + progress?: string; // Tasks: needs-action, in-process, completed, failed + due?: string; // Tasks only +} + +interface Calendar { + id: string; + name: string; + color?: string; + isVisible?: boolean; + sortOrder?: number; + myRights?: Record; +} + +interface Participant { + name?: string; + email?: string; + sendTo?: Record; + kind?: string; + roles?: Record; + participationStatus?: string; // needs-action, accepted, declined, tentative + expectReply?: boolean; +} + +interface RecurrenceRule { + frequency: string; // daily, weekly, monthly, yearly + interval?: number; + until?: string; + count?: number; + byDay?: Array<{ day: string; nthOfPeriod?: number }>; + byMonth?: string[]; + byMonthDay?: number[]; +} + +interface Alert { + trigger: { "@type": string; offset?: string; when?: string }; + action?: string; +} + +// ─── Files (FileNode) ─────────────────────────────────── +interface FileNode { + id: string; + name: string; + parentId?: string | null; + type: string; + blobId?: string; + size?: number; + created?: string; + updated?: string; +} +``` + +--- + +## Phase 3 — Email Operations (`api/email.ts`) + +### Fetch Mailboxes + +```typescript +async function getMailboxes(): Promise { + const res = await request([["Mailbox/get", { accountId }, "0"]]); + return res.methodResponses[0][1].list; +} +``` + +### Query Emails (Inbox List) + +```typescript +const EMAIL_LIST_PROPERTIES = [ + "id", + "threadId", + "mailboxIds", + "keywords", + "size", + "receivedAt", + "from", + "to", + "cc", + "subject", + "preview", + "hasAttachment", +]; + +async function queryEmails( + mailboxId: string, + options?: { + position?: number; + limit?: number; + sort?: Array<{ property: string; isAscending: boolean }>; + filter?: Record; + }, +): Promise<{ ids: string[]; total: number }> { + const res = await request([ + [ + "Email/query", + { + accountId, + filter: { inMailbox: mailboxId, ...options?.filter }, + sort: options?.sort ?? [{ property: "receivedAt", isAscending: false }], + position: options?.position ?? 0, + limit: options?.limit ?? 50, + calculateTotal: true, + }, + "0", + ], + ]); + return { + ids: res.methodResponses[0][1].ids, + total: res.methodResponses[0][1].total, + }; +} + +async function getEmails(ids: string[]): Promise { + const res = await request([ + [ + "Email/get", + { + accountId, + ids, + properties: EMAIL_LIST_PROPERTIES, + }, + "0", + ], + ]); + return res.methodResponses[0][1].list; +} +``` + +### Get Full Email (Thread View) + +```typescript +async function getFullEmail(id: string): Promise { + const res = await request([ + [ + "Email/get", + { + accountId, + ids: [id], + properties: [ + ...EMAIL_LIST_PROPERTIES, + "bodyStructure", + "textBody", + "htmlBody", + "attachments", + "blobId", + ], + fetchHTMLBodyValues: true, + fetchTextBodyValues: true, + maxBodyValueBytes: 512000, + }, + "0", + ], + ]); + return res.methodResponses[0][1].list[0]; +} + +async function getThread(threadId: string): Promise { + const res = await request([ + ["Thread/get", { accountId, ids: [threadId] }, "0"], + ]); + return res.methodResponses[0][1].list[0]; +} +``` + +### Mutate Emails + +```typescript +// Mark read/unread +async function setEmailKeywords( + emailId: string, + keywords: Record, +): Promise { + await request([ + [ + "Email/set", + { + accountId, + update: { [emailId]: { keywords } }, + }, + "0", + ], + ]); +} + +// Move to mailbox +async function moveEmail( + emailId: string, + fromMailboxId: string, + toMailboxId: string, +): Promise { + await request([ + [ + "Email/set", + { + accountId, + update: { + [emailId]: { + [`mailboxIds/${fromMailboxId}`]: null, + [`mailboxIds/${toMailboxId}`]: true, + }, + }, + }, + "0", + ], + ]); +} + +// Delete (move to Trash, or destroy if already in Trash) +async function deleteEmail( + emailId: string, + trashMailboxId: string, + currentMailboxId: string, +): Promise { + if (currentMailboxId === trashMailboxId) { + await request([["Email/set", { accountId, destroy: [emailId] }, "0"]]); + } else { + await moveEmail(emailId, currentMailboxId, trashMailboxId); + } +} +``` + +### Search + +```typescript +async function searchEmails( + query: string, + mailboxId?: string, + limit = 30, +): Promise { + const filter: Record = { + text: query.endsWith("*") ? query : `${query}*`, + }; + if (mailboxId) filter.inMailbox = mailboxId; + + const res = await request([ + [ + "Email/query", + { + accountId, + filter, + sort: [{ property: "receivedAt", isAscending: false }], + limit, + }, + "0", + ], + ]); + return res.methodResponses[0][1].ids; +} +``` + +### Send Email + +```typescript +async function sendEmail( + email: { + from: EmailAddress[]; + to: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + subject: string; + htmlBody?: string; + textBody?: string; + attachments?: Array<{ blobId: string; type: string; name: string }>; + inReplyTo?: string; + references?: string; + }, + identityId: string, +): Promise { + const emailCreate: Record = { + from: email.from, + to: email.to, + cc: email.cc, + bcc: email.bcc, + subject: email.subject, + mailboxIds: { [sentMailboxId]: true }, + keywords: { $seen: true }, + }; + + if (email.htmlBody) { + emailCreate.htmlBody = [{ partId: "html", type: "text/html" }]; + emailCreate.bodyValues = { html: { value: email.htmlBody } }; + } else { + emailCreate.textBody = [{ partId: "text", type: "text/plain" }]; + emailCreate.bodyValues = { text: { value: email.textBody ?? "" } }; + } + + if (email.attachments?.length) { + emailCreate.attachments = email.attachments.map((a) => ({ + blobId: a.blobId, + type: a.type, + name: a.name, + disposition: "attachment", + })); + } + + if (email.inReplyTo) { + emailCreate["header:In-Reply-To:asText"] = email.inReplyTo; + emailCreate["header:References:asText"] = + email.references ?? email.inReplyTo; + } + + await request( + [ + ["Email/set", { accountId, create: { draft: emailCreate } }, "0"], + [ + "EmailSubmission/set", + { + accountId, + create: { "sub-1": { emailId: "#draft", identityId } }, + }, + "1", + ], + ], + [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:submission", + ], + ); +} +``` + +--- + +## Phase 4 — Contacts Operations (`api/contacts.ts`) + +```typescript +async function getAddressBooks(): Promise { + const res = await request( + [["AddressBook/get", { accountId }, "0"]], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], + ); + return res.methodResponses[0][1].list; +} + +async function queryContacts( + filter?: { text?: string; inAddressBook?: string }, + limit = 100, +): Promise { + const res = await request( + [ + [ + "ContactCard/query", + { + accountId, + filter: filter ?? {}, + sort: [{ property: "name", isAscending: true }], + limit, + }, + "0", + ], + ], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], + ); + return res.methodResponses[0][1].ids; +} + +async function getContacts(ids: string[]): Promise { + const res = await request( + [["ContactCard/get", { accountId, ids }, "0"]], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], + ); + return res.methodResponses[0][1].list; +} + +async function createContact( + contact: Partial, + addressBookId: string, +): Promise { + const res = await request( + [ + [ + "ContactCard/set", + { + accountId, + create: { + "new-contact": { + ...contact, + addressBookIds: { [addressBookId]: true }, + }, + }, + }, + "0", + ], + ], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], + ); + return res.methodResponses[0][1].created["new-contact"]; +} + +async function updateContact( + id: string, + changes: Partial, +): Promise { + await request( + [["ContactCard/set", { accountId, update: { [id]: changes } }, "0"]], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], + ); +} + +async function deleteContacts(ids: string[]): Promise { + await request( + [["ContactCard/set", { accountId, destroy: ids }, "0"]], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], + ); +} +``` + +--- + +## Phase 5 — Calendar Operations (`api/calendar.ts`) + +```typescript +async function getCalendars(): Promise { + const res = await request( + [["Calendar/get", { accountId }, "0"]], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"], + ); + return res.methodResponses[0][1].list; +} + +const CALENDAR_EVENT_PROPERTIES = [ + "id", + "@type", + "uid", + "calendarIds", + "title", + "description", + "start", + "duration", + "timeZone", + "showWithoutTime", + "utcStart", + "utcEnd", + "status", + "freeBusyStatus", + "participants", + "alerts", + "recurrenceRules", + "recurrenceOverrides", + "excludedRecurrenceRules", + "links", + "created", + "updated", +]; + +async function queryEvents( + calendarIds: string[], + after: string, + before: string, +): Promise { + const res = await request( + [ + [ + "CalendarEvent/query", + { + accountId, + filter: { inCalendars: calendarIds, after, before, types: ["Event"] }, + sort: [{ property: "start", isAscending: true }], + limit: 500, + }, + "0", + ], + ], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"], + ); + return res.methodResponses[0][1].ids; +} + +async function getEvents(ids: string[]): Promise { + const res = await request( + [ + [ + "CalendarEvent/get", + { accountId, ids, properties: CALENDAR_EVENT_PROPERTIES }, + "0", + ], + ], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"], + ); + return res.methodResponses[0][1].list; +} + +async function createEvent( + event: Partial, + calendarId: string, +): Promise { + const res = await request( + [ + [ + "CalendarEvent/set", + { + accountId, + create: { + "new-event": { ...event, calendarIds: { [calendarId]: true } }, + }, + }, + "0", + ], + ], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"], + ); + return res.methodResponses[0][1].created["new-event"]; +} + +async function updateEvent( + id: string, + changes: Partial, +): Promise { + await request( + [["CalendarEvent/set", { accountId, update: { [id]: changes } }, "0"]], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"], + ); +} + +async function deleteEvents(ids: string[]): Promise { + await request( + [["CalendarEvent/set", { accountId, destroy: ids }, "0"]], + ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"], + ); +} +``` + +--- + +## Phase 6 — Blob Upload/Download (`api/blob.ts`) + +```typescript +async function uploadBlob( + uri: string, + type: string, + name: string, +): Promise<{ blobId: string; size: number }> { + // Use fetch with file URI from image picker / document picker + const response = await fetch( + session.uploadUrl.replace("{accountId}", accountId), + { + method: "POST", + headers: { + "Content-Type": type, + Authorization: authHeader, + }, + body: await fetch(uri).then((r) => r.blob()), + }, + ); + return response.json(); +} + +function getDownloadUrl(blobId: string, name?: string, type?: string): string { + return session.downloadUrl + .replace("{accountId}", accountId) + .replace("{blobId}", blobId) + .replace("{name}", encodeURIComponent(name ?? "download")) + .replace("{type}", encodeURIComponent(type ?? "application/octet-stream")); +} +``` + +--- + +## Phase 7 — Real-Time Push Updates (`api/push.ts`) + +### Server-Sent Events (SSE) + +```typescript +import EventSource from "react-native-sse"; // or polyfill + +interface StateChange { + "@type": "StateChange"; + changed: Record>; + // e.g. { "account-id": { "Email": "state123", "Mailbox": "state456" } } +} + +function connectEventSource( + session: JMAPSession, + accountId: string, + onStateChange: (change: StateChange) => void, +): () => void { + const url = session.eventSourceUrl + .replace("{types}", "*") + .replace("{closeafter}", "no") + .replace("{ping}", "30"); + + const es = new EventSource(url, { + headers: { Authorization: authHeader }, + }); + + es.addEventListener("state", (event) => { + const data: StateChange = JSON.parse(event.data); + onStateChange(data); + }); + + // Return cleanup function + return () => es.close(); +} +``` + +### Polling Fallback + +If SSE is not available (no `eventSourceUrl` in session), fall back to polling every 5 seconds: + +```typescript +function startPolling(interval = 5000): () => void { + const timer = setInterval(async () => { + const res = await request([ + ["Mailbox/get", { accountId, ids: null }, "m"], + ["Email/get", { accountId, ids: [] }, "e"], + ]); + // Compare state strings with cached state + // If different, trigger refresh + }, interval); + + return () => clearInterval(timer); +} +``` + +### State Change Handler + +When a state change is received: + +| Changed Type | Action | +| --------------- | -------------------------------------- | +| `Mailbox` | Re-fetch mailbox list (unread counts) | +| `Email` | Re-query current mailbox email list | +| `Thread` | Re-fetch open thread | +| `Calendar` | Re-fetch calendar list | +| `CalendarEvent` | Re-query events for visible date range | +| `ContactCard` | Re-fetch contact list | + +--- + +## Phase 8 — State Management (`stores/`) + +Use Zustand for lightweight state management (already common in Expo projects): + +```bash +npx expo install zustand +``` + +### Auth Store (`stores/auth-store.ts`) + +```typescript +interface AuthState { + isAuthenticated: boolean; + serverUrl: string | null; + username: string | null; + session: JMAPSession | null; + accountId: string | null; + + login: ( + serverUrl: string, + username: string, + password: string, + ) => Promise; + logout: () => void; + restoreSession: () => Promise; +} +``` + +### Email Store (`stores/email-store.ts`) + +```typescript +interface EmailState { + mailboxes: Mailbox[]; + currentMailboxId: string | null; + emails: Email[]; + totalEmails: number; + loading: boolean; + error: string | null; + + fetchMailboxes: () => Promise; + fetchEmails: (mailboxId: string, position?: number) => Promise; + fetchMore: () => Promise; // Infinite scroll + refreshEmails: () => Promise; // Pull-to-refresh + getFullEmail: (id: string) => Promise; + getThread: (threadId: string) => Promise; + toggleStar: (emailId: string) => Promise; + markRead: (emailId: string) => Promise; + moveToTrash: (emailId: string) => Promise; + searchEmails: (query: string) => Promise; +} +``` + +### Contacts Store (`stores/contacts-store.ts`) + +```typescript +interface ContactsState { + addressBooks: AddressBook[]; + contacts: ContactCard[]; + loading: boolean; + + fetchContacts: () => Promise; + searchContacts: (query: string) => Promise; + createContact: (contact: Partial) => Promise; + updateContact: (id: string, changes: Partial) => Promise; + deleteContact: (id: string) => Promise; +} +``` + +### Calendar Store (`stores/calendar-store.ts`) + +```typescript +interface CalendarState { + calendars: Calendar[]; + events: CalendarEvent[]; + loading: boolean; + + fetchCalendars: () => Promise; + fetchEvents: (start: string, end: string) => Promise; + createEvent: (event: Partial) => Promise; + updateEvent: (id: string, changes: Partial) => Promise; + deleteEvent: (id: string) => Promise; +} +``` + +--- + +## Phase 9 — Wire Screens to Real Data + +### LoginScreen + +Replace the mock `handleLogin`: + +```typescript +const { login } = useAuthStore(); +await login(serverUrl, username, password); +// On success → navigate to main tabs +// On failure → show error below form +``` + +Add a **Server URL** input field above email/password (the webmail auto-detects, but the mobile app should let users enter their server URL since it connects directly). + +### EmailListScreen + +Replace `MOCK_EMAILS`: + +```typescript +const { + mailboxes, + emails, + currentMailboxId, + fetchMailboxes, + fetchEmails, + loading, +} = useEmailStore(); + +useEffect(() => { + fetchMailboxes().then(() => { + const inbox = mailboxes.find((m) => m.role === "inbox"); + if (inbox) fetchEmails(inbox.id); + }); +}, []); + +// FlatList data = emails +// Pull-to-refresh = refreshEmails() +// Infinite scroll = onEndReached → fetchMore() +``` + +### EmailThreadScreen + +Replace mock thread data: + +```typescript +const { getThread, getFullEmail } = useEmailStore(); +const thread = await getThread(threadId); +const emails = await Promise.all(thread.emailIds.map((id) => getFullEmail(id))); +``` + +### ComposeScreen + +Wire to `sendEmail()`: + +```typescript +const { session } = useAuthStore(); +// Upload attachments first → get blobIds +// Build email object +// Call sendEmail(email, identityId) +``` + +### ContactsScreen + +Replace `MOCK_CONTACTS`: + +```typescript +const { contacts, fetchContacts, searchContacts } = useContactsStore(); +useEffect(() => { + fetchContacts(); +}, []); +``` + +### CalendarScreen + +Replace `MOCK_EVENTS`: + +```typescript +const { events, fetchEvents } = useCalendarStore(); +useEffect(() => { + const start = startOfMonth(currentDate).toISOString(); + const end = endOfMonth(currentDate).toISOString(); + fetchEvents(start, end); +}, [currentDate]); +``` + +--- + +## Phase 10 — Error Handling + +### Rate Limiting + +```typescript +class RateLimitError extends Error { + retryAfterMs: number; + constructor(retryAfterMs: number) { + super(`Rate limited. Retry after ${retryAfterMs}ms`); + this.retryAfterMs = retryAfterMs; + } +} + +// In request(): +if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + const ms = retryAfter + ? Number(retryAfter) * 1000 || Date.parse(retryAfter) - Date.now() + : 60000; + throw new RateLimitError(Math.min(ms, 300000)); // Cap at 5 minutes +} +``` + +### JMAP Method Errors + +```typescript +// Check each methodResponse for error tuples: +for (const [name, result, callId] of response.methodResponses) { + if (name === "error") { + throw new JMAPError(result.type, result.description); + } + // Also check notCreated, notUpdated, notDestroyed in /set responses +} +``` + +### Network Errors + +```typescript +// Wrap all requests in try/catch +// On network failure → show offline banner +// On 401 → redirect to login +// On JMAP error → show toast with description +``` + +--- + +## Dependencies to Install + +```bash +npx expo install zustand expo-secure-store +npm install react-native-sse # SSE polyfill for React Native +``` + +--- + +## Implementation Order + +1. `api/types.ts` — all interfaces (no dependencies) +2. `api/jmap-client.ts` — session + request core +3. `api/blob.ts` — upload/download helpers +4. `api/email.ts` — mailbox + email operations +5. `api/contacts.ts` — address book + contact operations +6. `api/calendar.ts` — calendar + event operations +7. `api/identity.ts` + `api/submission.ts` — sending +8. `stores/auth-store.ts` — login flow +9. `stores/email-store.ts` — wire EmailListScreen + EmailThreadScreen +10. `stores/contacts-store.ts` — wire ContactsScreen +11. `stores/calendar-store.ts` — wire CalendarScreen +12. `api/push.ts` — real-time updates (SSE + fallback polling) +13. Wire ComposeScreen to send flow +14. Wire SettingsScreen to Identity/get and VacationResponse + +--- + +## Testing + +- Test against Stalwart server at `https://your-server/.well-known/jmap` +- Verify session discovery returns valid `apiUrl`, `downloadUrl`, `uploadUrl` +- Verify Basic Auth login → session → Mailbox/get returns inbox +- Verify Email/query + Email/get returns real emails +- Verify Email/set can toggle `$seen` and `$flagged` keywords +- Verify EmailSubmission/set sends a real email +- Verify SSE connection receives state changes on new mail +- Test 401 re-auth flow and 429 rate limit backoff diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..bafd18a4 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,521 @@ +# Bulwark Mobile — React Native Port Plan + +## Architecture Overview + +``` +repos/react-native/ +├── src/ +│ ├── lib/ ← COPIED from web (pure logic, protocol clients) +│ ├── stores/ ← COPIED from web (Zustand stores, adapted persistence) +│ ├── screens/ ← NEW (React Navigation screens) +│ ├── components/ ← NEW (React Native UI components) +│ ├── hooks/ ← MIXED (some copied, some new for gestures/nav) +│ ├── navigation/ ← NEW (React Navigation config) +│ ├── theme/ ← NEW (RN styling system replacing CSS/Tailwind) +│ └── platform/ ← NEW (platform adapters: storage, notifications, crypto) +├── app.json +├── package.json +└── tsconfig.json +``` + +## Shared Code Strategy + +The web codebase at `lib/`, `stores/` contains ~86 files of reusable logic. +Rather than copy-paste, create a **shared package** approach: + +### Option A: Monorepo with shared package (recommended) + +``` +jmap-webmail/ +├── packages/ +│ ├── shared/ ← extracted from lib/ and stores/ +│ │ ├── jmap/ +│ │ ├── webdav/ +│ │ ├── sieve/ +│ │ ├── oauth/ +│ │ ├── smime/ +│ │ ├── stores/ +│ │ └── utils/ +│ ├── web/ ← current Next.js app (imports from shared) +│ └── mobile/ ← React Native app (imports from shared) +``` + +### Option B: Copy and maintain separately (simpler to start) + +Copy reusable files into `repos/react-native/src/lib/` and adapt. +Easier to start but diverges over time. + +**Recommendation**: Start with Option B to validate the RN app, migrate to Option A later. + +--- + +## Phase 0: Project Setup + +### Tasks + +- [ ] Initialize Expo project (SDK 53+, managed workflow) +- [ ] Configure TypeScript +- [ ] Install core dependencies (see below) +- [ ] Set up NativeWind (Tailwind CSS for RN) to maximize style reuse +- [ ] Configure React Navigation (native stack + bottom tabs) +- [ ] Create platform adapter layer (storage, crypto, notifications) + +### Dependencies + +```json +{ + "dependencies": { + "expo": "~53.0.0", + "react": "19.x", + "react-native": "0.79.x", + + "zustand": "^5.0.9", + "date-fns": "^4.1.0", + "clsx": "^2.1.1", + + "@react-navigation/native": "^7.x", + "@react-navigation/native-stack": "^7.x", + "@react-navigation/bottom-tabs": "^7.x", + + "nativewind": "^4.x", + "tailwind-merge": "^3.4.0", + + "expo-secure-store": "~14.x", + "@react-native-async-storage/async-storage": "^2.x", + "expo-notifications": "~0.29.x", + "expo-av": "~15.x", + "expo-file-system": "~18.x", + "expo-web-browser": "~14.x", + "expo-linking": "~7.x", + + "react-native-webview": "^14.x", + "react-native-gesture-handler": "~2.x", + "react-native-reanimated": "~3.x", + + "lucide-react-native": "^0.575.0", + + "postal-mime": "^2.7.4", + "jszip": "^3.10.1" + } +} +``` + +### Platform Adapter Layer + +Create `src/platform/` to abstract browser vs native differences: + +```typescript +// src/platform/storage.ts +import * as SecureStore from "expo-secure-store"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +export const secureStorage = { + getItem: (key: string) => SecureStore.getItemAsync(key), + setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value), + removeItem: (key: string) => SecureStore.deleteItemAsync(key), +}; + +export const storage = { + getItem: (key: string) => AsyncStorage.getItem(key), + setItem: (key: string, value: string) => AsyncStorage.setItem(key, value), + removeItem: (key: string) => AsyncStorage.removeItem(key), +}; +``` + +```typescript +// src/platform/notifications.ts +import * as Notifications from "expo-notifications"; + +export async function scheduleLocalNotification(title: string, body: string) { + await Notifications.scheduleNotificationAsync({ + content: { title, body }, + trigger: null, // immediate + }); +} +``` + +```typescript +// src/platform/auth.ts +import * as WebBrowser from "expo-web-browser"; +import * as Linking from "expo-linking"; + +export async function openOAuthFlow(authUrl: string): Promise { + const redirectUrl = Linking.createURL("auth/callback"); + const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUrl); + if (result.type === "success") return result.url; + throw new Error("Auth cancelled"); +} +``` + +--- + +## Phase 1: Core Logic Port (copy + adapt) + +### 1.1 Pure Utilities — Direct Copy + +These files have ZERO browser dependencies. Copy as-is: + +``` +lib/account-utils.ts +lib/calendar-alerts.ts +lib/calendar-event-normalization.ts +lib/calendar-invitation.ts +lib/calendar-participants.ts +lib/calendar-utils.ts +lib/color-transform.ts +lib/email-headers.ts +lib/file-preview.ts +lib/recurrence-expansion.ts +lib/reply-identity.ts +lib/signature-utils.ts +lib/sub-addressing.ts +lib/template-types.ts +lib/template-utils.ts +lib/thread-utils.ts +lib/tnef.ts +lib/utils.ts ← remove tailwind-merge (clsx) usage or keep with NativeWind +lib/validation.ts +lib/vcard.ts +``` + +### 1.2 Protocol Clients — Direct Copy + +These use `fetch()` which works in React Native: + +``` +lib/jmap/client.ts +lib/jmap/client-interface.ts +lib/jmap/types.ts +lib/jmap/search-utils.ts +lib/jmap/sieve-types.ts +lib/webdav/client.ts +lib/sieve/parser.ts +lib/sieve/generator.ts +lib/oauth/discovery.ts +lib/oauth/pkce.ts +lib/oauth/token-exchange.ts +lib/oauth/tokens.ts ← swap cookie storage → SecureStore +lib/stalwart/client.ts +lib/stalwart/credentials.ts +lib/admin/config-manager.ts +lib/admin/audit.ts +lib/admin/session.ts +lib/admin/types.ts +lib/demo/demo-client.ts +lib/demo/demo-data.ts +lib/demo/demo-utils.ts +``` + +### 1.3 Stores — Copy + Adapt Persistence + +All Zustand stores work in RN. Only change: replace `persist` middleware's +default `localStorage` with AsyncStorage. + +Create a shared persist config: + +```typescript +// src/platform/zustand-storage.ts +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createJSONStorage } from "zustand/middleware"; + +export const asyncStorageAdapter = createJSONStorage(() => ({ + getItem: async (name: string) => await AsyncStorage.getItem(name), + setItem: async (name: string, value: string) => + await AsyncStorage.setItem(name, value), + removeItem: async (name: string) => await AsyncStorage.removeItem(name), +})); +``` + +Then in each store that uses `persist`: + +```typescript +// Before (web): +persist(storeInit, { + name: "settings", + storage: createJSONStorage(() => localStorage), +}); + +// After (RN): +persist(storeInit, { name: "settings", storage: asyncStorageAdapter }); +``` + +Stores to adapt: + +- `auth-store.ts` — use SecureStore for tokens instead of AsyncStorage +- `smime-store.ts` — use SecureStore for private keys +- `settings-store.ts`, `locale-store.ts`, `template-store.ts`, `plugin-store.ts`, + `calendar-store.ts`, `theme-store.ts` — use AsyncStorage adapter +- `ui-store.ts` — replace `window.innerWidth` with `Dimensions.get('window')` + +### 1.4 Adapt Browser-Specific Libs + +| File | Adaptation | +| ----------------------- | ------------------------------------------------------------------------- | +| `email-sanitization.ts` | Don't sanitize for RN — render HTML emails inside `` | +| `notification-sound.ts` | Replace `AudioContext` → `expo-av` Audio.Sound | +| `settings-sync.ts` | Replace `localStorage` cache → AsyncStorage | +| `browser-navigation.ts` | Delete — use React Navigation instead | +| `iframe-bridge.ts` | Delete — not applicable | +| `theme-loader.ts` | Delete — use NativeWind/StyleSheet | +| `plugin-loader.ts` | Defer — plugins are a stretch goal | +| `error-reporting.ts` | Replace `navigator`/`window` → `Platform.OS`, `Application.nativeVersion` | + +--- + +## Phase 2: Navigation & Infrastructure + +### 2.1 Navigation Structure + +```typescript +// src/navigation/index.tsx +const Tab = createBottomTabNavigator(); +const Stack = createNativeStackNavigator(); + +function MainTabs() { + return ( + + + + + + + + ); +} + +function RootNavigator() { + return ( + + + + + ); +} +``` + +### Screen Mapping (web route → RN screen) + +| Web Route | RN Screen | Stack | +| ----------------- | ---------------------------- | ------------------ | +| `/login` | `LoginScreen` | Root | +| `/` (inbox) | `EmailListScreen` | EmailStack | +| email thread view | `EmailThreadScreen` | EmailStack | +| email composer | `ComposeScreen` | EmailStack (modal) | +| `/calendar` | `CalendarScreen` | CalendarStack | +| `/calendar/[id]` | `EventDetailScreen` | CalendarStack | +| `/contacts` | `ContactListScreen` | ContactsStack | +| `/contacts/[id]` | `ContactDetailScreen` | ContactsStack | +| `/contacts/new` | `ContactFormScreen` | ContactsStack | +| `/files` | `FileBrowserScreen` | FilesStack | +| `/settings/*` | `Settings*Screen` (multiple) | SettingsStack | + +### 2.2 Hooks — Adapt or Replace + +**Keep (works in RN):** + +- `use-calendar-alerts.ts` — wire to `expo-notifications` +- `use-config.ts` — pure state +- `use-sidebar-apps.ts` — pure state + +**Rewrite:** + +- `use-confirm-dialog.ts` → RN Alert.alert() or custom modal +- `use-context-menu.ts` → RN action sheet or custom bottom sheet +- `use-media-query.ts` → `useWindowDimensions()` from react-native +- `use-long-press.ts` → `Pressable` with `onLongPress` or gesture handler +- `use-email-drag.ts` → gesture handler PanGesture (or skip for v1) +- `use-mailbox-drop.ts` → gesture handler (or skip for v1) +- `use-tag-drop.ts` → gesture handler (or skip for v1) +- `use-keyboard-shortcuts.ts` → not applicable on mobile (skip) +- `use-focus-trap.ts` → not applicable on mobile (skip) +- `use-time-grid-interactions.ts` → gesture handler for calendar + +### 2.3 Internationalization + +Replace `next-intl` with `expo-localization` + `i18next`: + +- Reuse all translation JSON files from `locales/` directly +- Only change the i18n provider setup + +--- + +## Phase 3: UI Components — Build Order + +Build from the bottom up: primitives → composites → screens. + +### 3.1 UI Primitives (`src/components/ui/`) + +Build equivalents of your web `components/ui/`: + +| Web Component | RN Equivalent | Notes | +| ------------------ | ----------------------------- | ------------------------------- | +| `button.tsx` | `` + styles | NativeWind for Tailwind classes | +| `input.tsx` | `` | | +| `context-menu.tsx` | Action sheet / bottom sheet | `@gorhom/bottom-sheet` | +| `toast.tsx` | Toast notification | `react-native-toast-message` | +| `avatar.tsx` | `` + fallback `` | | +| Modal system | `` from RN | | +| Dropdown/Select | Bottom sheet picker | | +| Checkbox/Switch | `` from RN | | + +### 3.2 Email Components (highest priority) + +Build order: + +1. **MailboxList** — sidebar/drawer showing folders (Inbox, Sent, Drafts, etc.) +2. **EmailList** — virtualized list (`FlashList`) of email summaries +3. **EmailViewer** — render email body in `` with HTML sanitization +4. **EmailComposer** — rich text input (hardest part — see note below) +5. **ThreadView** — conversation thread rendering +6. **AttachmentViewer** — file type preview + download +7. **SearchBar** — email search with filter chips + +#### Rich Text Editor Note + +TipTap does NOT work in React Native. Options: + +- **`react-native-pell-rich-editor`** — basic HTML editor +- **`@10play/tentap-editor`** — TipTap port for RN (best option, maintains some API compatibility) +- **WebView-based editor** — load TipTap in a WebView (functional but slower) + +Recommendation: Use **`@10play/tentap-editor`** for closest parity with your TipTap setup. + +### 3.3 Calendar Components + +Build order: + +1. **MonthView** — grid of days with event dots +2. **WeekView** — 7-column time grid with event blocks +3. **DayView** — single-column time grid +4. **AgendaView** — flat list of upcoming events (mobile-friendly) +5. **EventDetail** — event info + RSVP +6. **EventForm** — create/edit event modal +7. **MiniCalendar** — compact date picker + +Consider using `react-native-calendars` as a base for month view. + +### 3.4 Contacts Components + +Build order: + +1. **ContactList** — alphabetical sections list (`SectionList`) +2. **ContactDetail** — vCard fields display +3. **ContactForm** — create/edit with field types +4. **ContactImport** — vCard file import + +### 3.5 Files Components + +Build order: + +1. **FileBrowser** — folder tree + file list +2. **FilePreview** — image/PDF preview +3. **FileUpload** — `expo-document-picker` + upload progress + +### 3.6 Settings Components + +Build order: + +1. **SettingsMenu** — grouped list of settings categories +2. **AccountSettings** — account info, password change +3. **AppearanceSettings** — theme picker, font size, density +4. **EmailSettings** — signatures, identity management +5. **CalendarSettings** — default calendar, week start +6. **NotificationSettings** — push notification preferences +7. **PrivacySettings** — S/MIME, trusted senders + +### 3.7 Layout Components + +- **AppShell** — tab bar + optional drawer +- **Header** — screen title + actions +- **SearchHeader** — collapsible search bar + +--- + +## Phase 4: Platform Features + +### 4.1 Push Notifications + +- Use `expo-notifications` for local calendar alerts +- Use push notification service for new email alerts (requires server-side push endpoint) + +### 4.2 OAuth Flow + +- Use `expo-web-browser` AuthSession for OAuth/OIDC login +- Deep link callback: `bulwarkmail://auth/callback` +- Store tokens in `expo-secure-store` + +### 4.3 Offline Support + +- Zustand `persist` with AsyncStorage gives basic offline state +- For full offline: cache emails/contacts in SQLite (`expo-sqlite`) + +### 4.4 File Handling + +- `expo-document-picker` for attachment selection +- `expo-file-system` for download/cache +- `expo-sharing` for share sheet integration + +### 4.5 Biometric Auth + +- `expo-local-authentication` for fingerprint/face unlock + +--- + +## Phase 5: Polish & Ship + +- [ ] App icons and splash screen +- [ ] Dark mode (leverage existing theme store logic) +- [ ] Haptic feedback (`expo-haptics`) +- [ ] App Store / Play Store submission +- [ ] Desktop via Expo for web or React Native Windows/macOS + +--- + +## What to Skip for v1 + +These features from the web app can be deferred: + +- **Admin panel** — keep web-only for now +- **Plugin system** — complex, defer +- **Drag and drop** (email to mailbox) — use swipe actions instead +- **Keyboard shortcuts** — not applicable on mobile +- **S/MIME** — complex crypto, defer to v2 +- **Sieve filter editor** — use simplified UI or defer +- **Custom themes/CSS** — use built-in light/dark +- **Tour/onboarding overlay** — defer +- **File browser** — defer if not core + +--- + +## v1 MVP Scope + +The minimum viable mobile app: + +1. **Login** (OAuth + basic auth) +2. **Email** (list, read, compose, reply, forward, search, attachments) +3. **Calendar** (month/day/agenda view, create/edit events, RSVP) +4. **Contacts** (list, view, create/edit, search) +5. **Settings** (account, appearance, notifications) +6. **Push notifications** (new email, calendar alerts) + +This gives users a functional mobile email+calendar+contacts app +while deferring advanced features to v2. + +--- + +## File Reuse Summary + +| Category | Files | Reusable | Action | +| --------------------------------------------------- | -------- | -------- | ------------------------- | +| Pure utils (`lib/*.ts`) | 19 | 100% | Copy | +| Protocol clients (`lib/jmap/`, `webdav/`, `sieve/`) | 10 | 100% | Copy | +| OAuth/Auth (`lib/oauth/`, `auth/`) | 7 | 70-80% | Copy + adapt storage | +| Demo data (`lib/demo/`) | 3 | 100% | Copy | +| Admin API (`lib/admin/`) | 7 | 90% | Copy | +| Stalwart client (`lib/stalwart/`) | 2 | 90% | Copy | +| Zustand stores (`stores/`) | 23 | 80% | Copy + swap persist layer | +| Translation files (`locales/`) | all | 100% | Copy | +| Hooks | 3 of 13 | 23% | Copy 3, rewrite 10 | +| Components | 0 of 123 | 0% | Full rewrite | + +**Total reusable**: ~74 files (~38% of codebase by file count, higher by logic value) diff --git a/STYLE_AUDIT_REPORT.md b/STYLE_AUDIT_REPORT.md new file mode 100644 index 00000000..8235f90c --- /dev/null +++ b/STYLE_AUDIT_REPORT.md @@ -0,0 +1,379 @@ +# React Native Style Audit Report + +**Generated:** March 29, 2026 +**Scope:** All 5 main screens vs. `tokens.ts` and webmail styling inventory + +--- + +## TOKEN COVERAGE SUMMARY + +### Colors Used Across All Screens + +| Token | EmailThread | Compose | Calendar | Contacts | Settings | Exists in tokens.ts? | +| ------------------------ | :---------: | :-----: | :------: | :------: | :------: | :------------------: | +| `colors.primary` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `colors.primaryBg` | — | ✅ | ✅ | ✅ | ✅ | ✅ | +| `colors.primaryLight` | — | — | — | — | ✅ | ✅ | +| `colors.text` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `colors.textSecondary` | ✅ | ✅ | — | ✅ | ✅ | ✅ | +| `colors.textMuted` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `colors.textInverse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `colors.background` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `colors.surface` | ✅ | — | ✅ | ✅ | ✅ | ✅ | +| `colors.surfaceHover` | ✅ | — | — | ✅ | ✅ | ✅ | +| `colors.surfaceActive` | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `colors.border` | ✅ | ✅ | ✅ | — | — | ✅ | +| `colors.borderLight` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `colors.error` | ✅ | — | — | — | ✅ | ✅ | +| `colors.errorBg` | — | — | — | — | ✅ | ✅ | +| `colors.starred` | ✅ | — | — | — | — | ✅ | +| `colors.successBg` | — | — | — | — | ✅ | ✅ | +| `colors.warningBg` | — | — | — | — | ✅ | ✅ | +| `colors.calendar.blue` | — | — | ✅ | — | ✅ | ✅ | +| `colors.calendar.purple` | — | — | ✅ | — | ✅ | ✅ | +| `colors.calendar.green` | ✅ | — | ✅ | — | ✅ | ✅ | +| `colors.calendar.orange` | — | — | ✅ | — | ✅ | ✅ | +| `colors.calendar.red` | — | — | ✅ | — | — | ✅ | +| `colors.calendar.pink` | — | — | — | — | ✅ | ✅ | +| `colors.calendar.teal` | — | — | ✅ | — | ✅ | ✅ | +| `colors.calendar.indigo` | — | — | — | — | ✅ | ✅ | +| `colors.tags.blue.dot` | ✅ | — | — | — | — | ✅ | +| `colors.tags.blue.bg` | ✅ | — | — | — | — | ✅ | + +### Spacing Tokens Used + +| Token | EmailThread | Compose | Calendar | Contacts | Settings | +| -------------- | :---------: | :-----: | :------: | :------: | :------: | +| `spacing.xs` | — | ✅ | ✅ | ✅ | ✅ | +| `spacing.sm` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `spacing.md` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `spacing.lg` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `spacing.xl` | — | ✅ | ✅ | — | ✅ | +| `spacing.xxl` | — | — | — | — | — | +| `spacing.xxxl` | — | — | ✅ | ✅ | — | + +### Typography Tokens Used + +| Token | EmailThread | Compose | Calendar | Contacts | Settings | +| -------------------------- | :---------: | :-----: | :------: | :------: | :------: | +| `typography.h2` | ✅ | — | — | — | — | +| `typography.h3` | — | — | ✅ | ✅ | ✅ | +| `typography.body` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `typography.bodyMedium` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `typography.caption` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `typography.captionMedium` | ✅ | ✅ | ✅ | — | — | +| `typography.small` | ✅ | — | ✅ | ✅ | ✅ | +| `typography.h1` | — | — | — | — | — | +| `typography.bodySemibold` | — | — | — | — | — | +| `typography.bodyBold` | — | — | — | — | — | +| `typography.base` | — | — | — | — | — | +| `typography.baseMedium` | — | — | — | — | — | +| `typography.tabLabel` | — | — | — | — | — | + +--- + +## MISSING TOKENS (defined in tokens.ts but never used) + +### Never-Used Colors + +- `colors.primaryDark` — no screen references it +- `colors.primaryBgHover` — no screen references it +- `colors.primaryForeground` — no screen uses it (screens use `textInverse` instead) +- `colors.card` / `colors.cardForeground` — not referenced +- `colors.textLink` — no screen references it +- `colors.borderFocus` — no screen references it +- `colors.success` / `colors.successForeground` — success color never used (only `successBg`) +- `colors.warning` / `colors.warningForeground` — warning never used (only `warningBg`) +- `colors.infoForeground` — never used +- `colors.unread` / `colors.read` — email list uses hardcoded approaches instead +- `colors.flagged` / `colors.draft` — not used anywhere +- `colors.navActive` / `colors.navInactive` / `colors.navBadge` — not used in screens (likely in navigator) +- `colors.secondary` / `colors.secondaryForeground` — never used in screens +- `colors.muted` / `colors.mutedForeground` — never used (screens use textMuted/textSecondary) +- `colors.accent` / `colors.accentForeground` — never used +- `colors.selection` / `colors.selectionForeground` — never used +- `colors.popover` / `colors.popoverForeground` — never used +- `colors.chart1–5` — never used +- All tag colors except `tags.blue` — never used in screens + +### Never-Used Spacing + +- `spacing.xxl` (24px) — never referenced in any screen + +### Never-Used Typography + +- `typography.h1` — no screen uses it +- `typography.bodySemibold` / `typography.bodyBold` — never used +- `typography.base` / `typography.baseMedium` — never used (email body should use `base` for 16px content) +- `typography.tabLabel` — not used in screens (likely in tab bar) + +### Never-Used componentSizes + +- The entire `componentSizes` object is **never imported or used by any screen**. All screens hardcode sizes. + +--- + +## PER-SCREEN DETAILED AUDIT + +--- + +### 1. EmailThreadScreen.tsx + +#### Hardcoded Values (should use tokens) + +| Line/Style | Current Value | Should Be | Priority | +| ------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------- | +| `toolbarBtn` width/height | `40` | `componentSizes.buttonMd` (40) | Low (matches) | +| `messageAvatar` size | `36, borderRadius: 18` | `componentSizes.buttonSm` (36) | Low (matches) | +| `messageAvatarText` fontSize | `15` | No token — should be `typography.body.fontSize` (14) or 16 | **Medium** | +| `attachmentIcon` size | `36` | `componentSizes.buttonSm` (36) | Low (matches) | +| `attachmentIconText` fontSize | `10` | `typography.small.fontSize` (10) | Low (matches) | +| `labelBadge` paddingHorizontal | `8` | `spacing.sm` (8) | Low (matches) | +| `labelBadge` paddingVertical | `3` | Non-standard — should be `2` or `4` (multiple of 4) | **Medium** | +| `labelBadge` gap | `4` | `spacing.xs` (4) | Low (matches) | +| `quickActionBtn` gap | `6` | Non-standard — should be `spacing.xs` (4) or `spacing.sm` (8) | Low | +| `messageCardLast` borderColor | `colors.primary + '30'` | Inline opacity hack — needs a proper token | **Medium** | +| `toolbarActions` gap | `2` | Non-standard — should be `spacing.xs` (4) | Low | +| `scrollContainer` paddingBottom | `40` | Should use `spacing.xxxl + spacing.sm` (40) or define a token | Low | +| `subjectText` lineHeight | `28` override | Redundant — `typography.h2` already has lineHeight 28 | Low | +| `messageBodyText` lineHeight | `22` | Webmail body uses lineHeight: 1.6 → at 14px = 22.4, at 16px = 25.6. Should use `typography.base` for email bodies | **High** | + +#### Webmail Discrepancies + +| Area | RN Current | Webmail Pattern | Fix | +| ------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| **Email body font size** | `typography.body` (14px) | Email content uses `text-sm` (14px) for metadata but body HTML is `14px line-height: 1.6` | OK for thread preview but if rendering full HTML, should use `typography.base` (16px) | +| **Toolbar icon size** | 22px (back), 20px (actions) | Web: w-5 h-5 = 20px for all | **Back arrow should be 20, not 22** | +| **Toolbar button size** | 40×40 | Web: w-8 h-8 = 32px with p-1.5 | **Should be 32×32** — currently oversized | +| **Toolbar padding** | `spacing.sm` (8px) | Web: py-3 px-4 = 12px vert, 16px horiz | **Should be paddingHorizontal: spacing.lg, paddingVertical: spacing.md** | +| **Attachment section bg** | `colors.surfaceHover` | Web: bg-muted, border rounded | Matches dark mode | +| **Quick action border** | `colors.border` | Web: 1px solid --color-border | ✅ Matches | +| **Subject area padding** | `paddingTop: spacing.sm` (8px) | Web: p-6 = 24px | **Should be `spacing.xxl` (24px)** | + +--- + +### 2. ComposeScreen.tsx + +#### Hardcoded Values + +| Line/Style | Current Value | Should Be | Priority | +| -------------------------------- | ------------- | -------------------------------------------------------------- | --------------- | +| `headerBtn` size | `40×40` | Web: 32×32 (w-8 h-8) | **Medium** | +| `sendButton` paddingVertical | `8` | `spacing.sm` (8) ✅ | OK | +| `sendButton` gap | `6` | Non-standard — should be `spacing.xs` (4) | Low | +| `fieldLabel` width | `52` | Hardcoded — should be responsive or a constant | Low | +| `fieldLabel` paddingTop | `6` | Non-standard value | Low | +| `chipText` maxWidth | `200` | Hardcoded | Low | +| `chip` paddingHorizontal | `10` | Non-standard — should be `spacing.sm` (8) or `spacing.md` (12) | Low | +| `chip` paddingVertical | `4` | `spacing.xs` (4) ✅ | OK | +| `recipientInput` minWidth | `100` | Hardcoded | Low | +| `recipientInput` paddingVertical | `4` | `spacing.xs` (4) ✅ | OK | +| `bodyInput` minHeight | `200` | Matches web min-height: 200px ✅ | OK | +| `quoteBorder` width | `3` | Web: border-left 2px solid | **Should be 2** | +| `quoteText` lineHeight | `18` | No matching token | Low | +| `formatBtn` size | `40×36` | Web: w-8 h-8 = 32×32 | **Medium** | +| `fieldSeparator` height | `0` | Dead code — does nothing | Low (cleanup) | + +#### Webmail Discrepancies + +| Area | RN Current | Webmail Pattern | Fix | +| ------------------------ | ----------------------------- | ------------------------------------------------- | ---------------------------------------------------- | +| **Header height** | Auto (sm padding ≈ 40px) | Web: p-4 = 16px top/bottom = ~56px with content | **Use `componentSizes.headerHeight` (56px)** | +| **Send button height** | Auto (~32px) | Web: h-9 = 36px | **Should be height: `componentSizes.buttonSm` (36)** | +| **Field min-height** | `44` | Touch target ✅ Good for mobile | OK | +| **Format toolbar icons** | 18px | Web: w-8 h-8 button with smaller icon | Icon size OK, button 40×36 → **should be 32×32** | +| **Quote border width** | `3px` | Web: 2px | **Should be 2px** | +| **Input field border** | None (no border on inputs) | Web: 1px solid --color-border, border-radius: 4px | **Missing input field borders** | +| **Subject input weight** | `typography.bodyMedium` (500) | Web: font-semibold (600) in some states | **Should use `bodySemibold` for emphasis** | + +--- + +### 3. CalendarScreenNew.tsx + +#### Hardcoded Values + +| Line/Style | Current Value | Should Be | Priority | +| ------------------------------- | ------------------------- | ------------------------------------------------ | ----------------- | +| `headerSubtitle` marginTop | `2` | Non-standard — should be spacing token | Low | +| `viewToggle` padding | `2` | Non-standard | Low | +| `viewToggleBtn` size | `32×32` | Web: w-8 h-8 = 32 ✅ | OK | +| `addButton` size | `36×36` | `componentSizes.buttonSm` (36) ✅ | OK | +| `todayBtn` paddingVertical | `6` | Non-standard | Low | +| `weekdayRow` marginBottom | `4` | `spacing.xs` (4) ✅ | OK | +| `dayNumber` size | `34×34, borderRadius: 17` | Non-standard — should be 32 or 36 | **Medium** | +| `eventDotSmall` | `5×5, borderRadius: 3` | `componentSizes.eventDot` (6) and borderRadius 3 | **Should be 6×6** | +| `eventDotsRow` gap | `2` | Non-standard | Low | +| `eventDotsRow` height | `5` | Should be `6` to match eventDot | Low | +| `eventColorBar` width | `4` | Web: border-left 3px | **Should be 3px** | +| `eventBody` gap | `4` | `spacing.xs` (4) ✅ | OK | +| `allDayBadge` paddingHorizontal | `8` | `spacing.sm` (8) ✅ | OK | +| `allDayBadge` paddingVertical | `2` | Non-standard | Low | + +#### Webmail Discrepancies + +| Area | RN Current | Webmail Pattern | Fix | +| -------------------------- | ---------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------ | +| **Today circle** | `backgroundColor: colors.primary` (solid fill) | Web: ring 2px solid primary (border only, not fill) | **Should be border style, not fill** — this is a significant visual difference | +| **Selected day** | `primaryBg + 1.5px border primary` | Web: bg-selection with primary ring | Close enough ✅ | +| **Day cell size** | 34×34 | Web: responsive grid cells, typically 32–40px | **Use 36 for better touch targets** | +| **Event card border-left** | `width: 4` (eventColorBar) | Web: 3px solid event-color | **Should be 3px** | +| **Event dot size** | 5×5 | Web: w-1.5 = 6px | **Should be 6×6 (`componentSizes.eventDot`)** | +| **Empty state icon** | `colors.surfaceActive` | Web: text-muted-foreground | **Should be `colors.textMuted`** | +| **Calendar grid lines** | Not visible | Web: border 1px per day cell | Minor — mobile intentionally cleaner | + +--- + +### 4. ContactsScreenNew.tsx + +#### Hardcoded Values + +| Line/Style | Current Value | Should Be | Priority | +| ---------------------------------- | ------------------------- | ---------------------------------------------------------------------------- | ------------------- | +| `headerCount` paddingHorizontal | `8` | `spacing.sm` (8) ✅ | OK | +| `headerCount` paddingVertical | `2` | Non-standard | Low | +| `headerBtn` size | `40×40` | Web: 32×32 | **Medium** | +| `addButton` size | `36×36` | `componentSizes.buttonSm` (36) ✅ | OK | +| `searchBar` height | `40` | `componentSizes.inputHeight` (40) ✅ | OK | +| `contactAvatar` size | `44×44, borderRadius: 22` | Web: w-10 h-10 = 40px | **Should be 40×40** | +| `contactActionBtn` size | `32×32` | OK for mobile touch | OK | +| `separator` marginLeft | `76` | Hardcoded — should be avatar (44) + marginRight (12) + paddingLeft (16) = 72 | **Should be 72** | +| `contactEmail` marginTop | `1` | Non-standard | Low | +| `contactCompany` marginTop | `1` | Non-standard | Low | +| `alphabetIndex` right | `2` | Non-standard | Low | +| `alphabetIndex` top | `'20%'` | Hardcoded % | Low | +| `alphabetLetter` paddingVertical | `1` | Non-standard | Low | +| `alphabetLetter` paddingHorizontal | `4` | `spacing.xs` (4) ✅ | OK | + +#### Webmail Discrepancies + +| Area | RN Current | Webmail Pattern | Fix | +| ----------------------- | ------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------- | +| **Contact avatar size** | 44px | Web: w-10 = 40px | **Should be 40px (`componentSizes.avatarMd`)** | +| **Search input height** | 40px | Web: h-8 = 32px | **Should be 32px for web parity, but 40px is better for mobile touch** — keep as-is | +| **Header button size** | 40×40 | Web: 32×32 | **Should be 36×36 for mobile** | +| **Section header bg** | `colors.background` | Web: bg-background ✅ | Matches | +| **Contact row padding** | `paddingVertical: spacing.md` (12px) | Web: var(--density-item-py) = 12px default | ✅ Matches | +| **Empty state spacing** | `spacing.xxxl * 2` (64px) | OK for mobile | OK | + +--- + +### 5. SettingsScreenNew.tsx + +#### Hardcoded Values + +| Line/Style | Current Value | Should Be | Priority | +| ----------------------------- | -------------------------------------------------- | ----------------------------------------------------------------- | ------------------ | +| `profileAvatar` size | `52×52, borderRadius: 26` | `componentSizes.avatarLg` (48) | **Should be 48px** | +| `settingIcon` size | `32×32` | `componentSizes.avatarSm` (32) ✅ | OK | +| `planBadge` paddingHorizontal | `10` | Non-standard — should be `spacing.sm` (8) or `spacing.md` (12) | Low | +| `planBadge` paddingVertical | `4` | `spacing.xs` (4) ✅ | OK | +| `sectionSeparator` marginLeft | `56` | Hardcoded — should be icon(32) + padding(12) + gap(12) = 56 | OK (correct calc) | +| `sectionTitle` letterSpacing | `0.5` | Matches web uppercase tracking | ✅ | +| `signOutBtn` borderColor | `colors.error + '40'` | Inline opacity hack — needs proper token | **Medium** | +| Hardcoded `iconBg` colors | `'#faf5ff'`, `'#f0fdfa'`, `'#eef2ff'`, `'#fdf2f8'` | **These are light-mode Tailwind colors hardcoded in dark theme!** | **CRITICAL** | + +#### Webmail Discrepancies + +| Area | RN Current | Webmail Pattern | Fix | +| -------------------------- | ------------------------------------------ | -------------------------------------------------- | ---------------------------------------------------------- | +| **Profile avatar** | 52px | Web: w-16 = 64px (detail) or w-12 = 48px (card) | **Should be 48px** | +| **Setting row padding** | `spacing.md` (12px) vertical | Web: py-3 = 12px ✅ | Matches | +| **Toggle track colors** | `surfaceActive`/`primaryLight` | Web: --color-muted (off) / --color-primary (on) | **Track off should be `colors.muted` or `colors.surface`** | +| **Toggle thumb** | `colors.primary`/`colors.textMuted` | Web: --color-background thumb | **Thumb should be `colors.background` (white circle)** | +| **Section card bg** | `colors.surface` | Web: bg-background with border | ✅ Works in dark | +| **Hardcoded light iconBg** | `#faf5ff`, `#f0fdfa`, `#eef2ff`, `#fdf2f8` | These are Tailwind \*-50 values (LIGHT mode only!) | **CRITICAL: Use dark-mode equivalents** | + +--- + +## PRIORITIZED FIX LIST + +### 🔴 CRITICAL (Visual bugs / Wrong theme values) + +| # | Screen | Issue | Fix | +| --- | --------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 1 | **Settings** | Hardcoded light-mode icon backgrounds (`#faf5ff`, `#f0fdfa`, `#eef2ff`, `#fdf2f8`) in a dark theme | Replace with dark equivalents: `'rgba(250,245,255,0.08)'` pattern or add `colors.calendar.*.bg` tokens | +| 2 | **Settings** | Toggle Switch colors wrong — track uses `surfaceActive`/`primaryLight`, thumb uses `primary`/`textMuted` | Track: `colors.surface` (off) / `colors.primary` (on). Thumb: always `colors.background` | +| 3 | **Calendar** | Today circle uses solid fill (`backgroundColor: primary`) | Web uses border/ring only. Change to `borderWidth: 2, borderColor: colors.primary, backgroundColor: 'transparent'` | +| 4 | **All screens** | `componentSizes` never imported — all sizes hardcoded | Import and use `componentSizes` for avatars, buttons, inputs consistently | + +### 🟠 HIGH (Noticeable sizing/spacing differences) + +| # | Screen | Issue | Fix | +| --- | --------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- | +| 5 | **EmailThread** | Back arrow is 22px, should be 20px | Change to `20` | +| 6 | **EmailThread** | Toolbar buttons 40×40, web is 32×32 | Change to `componentSizes.avatarSm` (32) or 36 for mobile | +| 7 | **EmailThread** | Subject area padding too small (8px top) | Change `paddingTop: spacing.sm` → `spacing.xxl` (24px) | +| 8 | **EmailThread** | Email body should use `typography.base` (16px) not `typography.body` (14px) | Use `base` for actual email content | +| 9 | **Compose** | Quote border width 3px, web is 2px | Change to `2` | +| 10 | **Compose** | Format toolbar buttons 40×36, web is 32×32 | Change to 32×32 or 36×36 | +| 11 | **Compose** | Send button has no explicit height (auto ~32px), web is 36px | Add `height: componentSizes.buttonSm` (36) | +| 12 | **Calendar** | Event dot 5×5, token says 6×6 | Change to `componentSizes.eventDot` (6) | +| 13 | **Calendar** | Event color bar width 4px, web is 3px | Change to `3` | +| 14 | **Contacts** | Avatar 44px, web is 40px | Change to `componentSizes.avatarMd` (40) | +| 15 | **Settings** | Profile avatar 52px, web uses 48px | Change to `componentSizes.avatarLg` (48) | + +### 🟡 MEDIUM (Minor visual polish) + +| # | Screen | Issue | Fix | +| --- | --------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| 16 | **EmailThread** | `messageAvatarText` fontSize 15, no matching token | Use `typography.body.fontSize` (14) | +| 17 | **EmailThread** | `messageCardLast` borderColor uses `+ '30'` inline hack | Add `colors.primaryBorder` token or use `colors.selection` | +| 18 | **Settings** | `signOutBtn` borderColor uses `+ '40'` inline hack | Add `colors.errorBorder` token or use `rgba(239,68,68,0.25)` | +| 19 | **Calendar** | Day number 34×34 — non-standard | Change to 36 (`componentSizes.buttonSm`) | +| 20 | **Calendar** | Empty state icon uses `surfaceActive` | Should be `colors.textMuted` | +| 21 | **Compose** | Input fields have no borders | Add `borderWidth: 1, borderColor: colors.border, borderRadius: radius.xs` | +| 22 | **Contacts** | Separator marginLeft hardcoded 76 | Calculate from avatar(40) + spacing.md(12) + spacing.lg(16) = 68 | +| 23 | **All** | Many non-standard spacing values (1, 2, 3, 6, 10) not aligning to 4px grid | Audit each and round to nearest `spacing.*` token | + +### 🟢 LOW (Nice-to-have / Cleanup) + +| # | Screen | Issue | Fix | +| --- | ------------ | ------------------------------------------------------------------------ | ------------------------------------------------ | +| 24 | **Compose** | `fieldSeparator` has `height: 0` — dead style | Remove it | +| 25 | **All** | Gap values of `2` used in several places | Change to `spacing.xs` (4) minimum | +| 26 | **Calendar** | `headerSubtitle` marginTop: 2 | Use `spacing.xs` (4) or remove | +| 27 | **Contacts** | `contactEmail`/`contactCompany` marginTop: 1 | Use `2` minimum | +| 28 | **All** | Inline opacity hacks (appending `'20'`, `'30'`, `'40'` to color strings) | Create proper opacity tokens or utility function | + +--- + +## NEW TOKENS RECOMMENDED + +Add these to `tokens.ts`: + +```typescript +// Add to colors: +colors.primaryBorder: 'rgba(59, 130, 246, 0.19)', // primary + '30' replacement +colors.errorBorder: 'rgba(239, 68, 68, 0.25)', // error + '40' replacement + +// Add to colors.calendar (dark-mode icon backgrounds): +colors.calendar.purpleBg: 'rgba(167, 139, 250, 0.08)', // replaces #faf5ff +colors.calendar.tealBg: 'rgba(45, 212, 191, 0.08)', // replaces #f0fdfa +colors.calendar.indigoBg: 'rgba(129, 140, 248, 0.08)', // replaces #eef2ff +colors.calendar.pinkBg: 'rgba(244, 114, 182, 0.08)', // replaces #fdf2f8 +colors.calendar.orangeBg: 'rgba(251, 191, 36, 0.08)', // replaces warningBg already exists + +// Or better: a utility function +export function withOpacity(color: string, opacity: number): string { + // Parse hex and return rgba +} +``` + +--- + +## SUMMARY STATISTICS + +| Metric | Count | +| ------------------------------- | ---------------------- | +| Total unique issues found | 28 | +| 🔴 Critical fixes | 4 | +| 🟠 High priority | 11 | +| 🟡 Medium priority | 8 | +| 🟢 Low priority | 5 | +| Unused color tokens | 27 | +| Unused typography tokens | 6 | +| Unused spacing tokens | 1 (`xxl`) | +| `componentSizes` usage | **0** (never imported) | +| Hardcoded non-token values | ~35 instances | +| Inline opacity hacks (`+ 'XX'`) | 3 instances | +| Light-mode colors in dark theme | 4 values in Settings | diff --git a/STYLE_TRANSFER_GUIDE.md b/STYLE_TRANSFER_GUIDE.md new file mode 100644 index 00000000..887b9bce --- /dev/null +++ b/STYLE_TRANSFER_GUIDE.md @@ -0,0 +1,310 @@ +# Style & Component Transfer Guide + +> How Bulwark Webmail's design system is translated into the React Native mobile app. + +--- + +## Architecture Overview + +The webmail uses **Next.js + Tailwind CSS** with CSS custom properties (`--color-*`). The mobile app uses **Expo + React Native StyleSheet** with a shared token file that mirrors those CSS variables as JavaScript constants. + +``` +Webmail (Tailwind CSS) Mobile (React Native) +───────────────────── ───────────────────── +globals.css / CSS variables →→→ src/theme/tokens.ts +components/ui/*.tsx →→→ src/components/*.tsx +Tailwind utility classes →→→ StyleSheet.create({}) +``` + +The transfer follows a strict pattern: every Tailwind class used in the webmail has a deterministic React Native equivalent defined through the token file, and every shared UI component in the mobile app is a 1:1 port of a webmail component. + +--- + +## Token System (`src/theme/tokens.ts`) + +The token file is the single source of truth. It exports five objects that map directly to the webmail's Qui Dark theme. + +### Colors + +| Token | Value | Webmail CSS Variable | +| -------------------------- | --------- | ---------------------------- | +| `colors.background` | `#09090b` | `--color-background` | +| `colors.text` | `#fafafa` | `--color-foreground` | +| `colors.primary` | `#3b82f6` | `--color-primary` | +| `colors.primaryForeground` | `#ffffff` | `--color-primary-foreground` | +| `colors.card` | `#141414` | `--color-card` | +| `colors.cardForeground` | `#fafafa` | `--color-card-foreground` | +| `colors.border` | `#27272a` | `--color-border` | +| `colors.muted` | `#18181b` | `--color-muted` | +| `colors.mutedForeground` | `#a1a1aa` | `--color-muted-foreground` | +| `colors.accent` | `#27272a` | `--color-accent` | +| `colors.error` | `#ef4444` | `--color-destructive` | +| `colors.warning` | `#ca8a04` | `--color-warning` | +| `colors.success` | `#22c55e` | `--color-success` | + +Additional semantic aliases exist (`textMuted`, `textSecondary`, `surface`, `surfaceHover`, etc.) that map to the same underlying palette for convenience. + +### Spacing (Tailwind rem → px) + +| Token | Value | Tailwind Equivalent | +| -------------- | ----- | ------------------- | +| `spacing.xs` | `4` | `p-1` (0.25rem) | +| `spacing.sm` | `8` | `p-2` (0.5rem) | +| `spacing.md` | `12` | `p-3` (0.75rem) | +| `spacing.lg` | `16` | `p-4` (1rem) | +| `spacing.xl` | `20` | `p-5` (1.25rem) | +| `spacing.xxl` | `24` | `p-6` (1.5rem) | +| `spacing.xxxl` | `32` | `p-8` (2rem) | + +**Conversion rule:** `Tailwind rem × 16 = RN pixels`. Hard-coded pixel values (e.g., `py-3.5` = 14px) are written inline where no token exists. + +### Border Radius + +| Token | Value | Tailwind | +| ------------- | ------ | -------------- | +| `radius.sm` | `6` | `rounded-md` | +| `radius.md` | `8` | `rounded-lg` | +| `radius.lg` | `12` | `rounded-xl` | +| `radius.full` | `9999` | `rounded-full` | + +### Typography + +| Token | fontSize | fontWeight | Tailwind | +| ----------------------- | -------- | ---------- | ------------------------- | +| `typography.h1` | 24 | 700 | `text-2xl font-bold` | +| `typography.h2` | 20 | 600 | `text-xl font-semibold` | +| `typography.h3` | 18 | 600 | `text-lg font-semibold` | +| `typography.body` | 14 | 400 | `text-sm` | +| `typography.bodyMedium` | 14 | 500 | `text-sm font-medium` | +| `typography.caption` | 12 | 400 | `text-xs` | +| `typography.small` | 10 | 500 | `text-[10px] font-medium` | + +### Component Sizes + +| Token | Value | Webmail Origin | +| ----------------------------- | ----- | -------------- | +| `componentSizes.headerHeight` | `56` | `h-14` | +| `componentSizes.inputHeight` | `40` | `h-10` | +| `componentSizes.buttonMd` | `40` | `h-10` | +| `componentSizes.buttonSm` | `36` | `h-9` | +| `componentSizes.buttonLg` | `44` | `h-11` | +| `componentSizes.toggleWidth` | `44` | `w-11` | +| `componentSizes.toggleHeight` | `24` | `h-6` | +| `componentSizes.toggleThumb` | `16` | `h-4 w-4` | +| `componentSizes.avatarMd` | `40` | `w-10 h-10` | + +--- + +## Component Transfer Map + +Each mobile component in `src/components/` is a direct port of a webmail component. The table below shows the mapping. + +| Mobile Component | Webmail Source | Key File | +| ---------------- | ------------------------------------------------------------------- | --------------------------------- | +| `Button` | `components/ui/button.tsx` | `src/components/Button.tsx` | +| `Input` | `components/ui/input.tsx` | `src/components/Input.tsx` | +| `Badge` | `components/ui/badge.tsx` | `src/components/Badge.tsx` | +| `Card` | `components/ui/card.tsx` | `src/components/Card.tsx` | +| `ToggleSwitch` | `components/settings/settings-section.tsx` (ToggleSwitch) | `src/components/ToggleSwitch.tsx` | +| `RadioGroup` | `components/settings/settings-section.tsx` (RadioGroup) | `src/components/RadioGroup.tsx` | +| `Dialog` | `components/ui/confirm-dialog.tsx` | `src/components/Dialog.tsx` | +| `SettingItemRow` | `components/settings/settings-section.tsx` (SettingItem) | `src/components/Card.tsx` | +| `SectionHeader` | `components/settings/settings-section.tsx` (SettingsSection header) | `src/components/Card.tsx` | + +All components are barrel-exported from `src/components/index.ts`. + +--- + +## Translating Tailwind to React Native StyleSheet + +This section documents the exact translation patterns used throughout the codebase. + +### Layout + +| Tailwind | React Native | +| ----------------------------------- | ----------------------------------------------------------------------------- | +| `flex items-center justify-between` | `flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'` | +| `flex-col` | `flexDirection: 'column'` (default) | +| `flex-1` | `flex: 1` | +| `gap-3` | `gap: 12` (3 × 4px) | +| `w-full` | `width: '100%'` or parent flex | +| `self-start` | `alignSelf: 'flex-start'` | + +### Spacing + +| Tailwind | React Native | +| ------------------------------ | ------------------------------------------------- | +| `px-5` | `paddingHorizontal: 20` | +| `py-3.5` | `paddingVertical: 14` | +| `mx-5 my-2` | `marginHorizontal: 20, marginVertical: 8` | +| `p-4` | `padding: spacing.lg` (16) | +| `mt-1` | `marginTop: 4` | +| `space-y-4` (between children) | `gap: 16` on parent or `marginBottom` on children | + +### Borders + +| Tailwind | React Native | +| ------------------------ | -------------------------------------------------------- | +| `border border-input` | `borderWidth: 1, borderColor: colors.border` | +| `border-b border-border` | `borderBottomWidth: 1, borderBottomColor: colors.border` | +| `border-t` | `borderTopWidth: 1, borderTopColor: colors.border` | +| `rounded-md` | `borderRadius: radius.sm` (6) | +| `rounded-lg` | `borderRadius: radius.md` (8) | +| `rounded-full` | `borderRadius: radius.full` (9999) | + +### Colors & Opacity + +| Tailwind | React Native | +| ----------------------- | ---------------------------------------------------------------- | +| `bg-background` | `backgroundColor: colors.background` | +| `text-foreground` | `color: colors.text` | +| `text-muted-foreground` | `color: colors.mutedForeground` | +| `text-destructive` | `color: colors.error` | +| `bg-warning/15` | `backgroundColor: 'rgba(202, 138, 4, 0.15)'` | +| `hover:bg-muted` | `pressed && { backgroundColor: colors.muted }` (via `Pressable`) | +| `disabled:opacity-50` | `opacity: 0.5` applied conditionally | + +### Typography + +| Tailwind | React Native | +| ------------------------------------------------ | --------------------------------------------------------------------------------- | +| `text-sm` | `...typography.body` (fontSize: 14) | +| `text-sm font-medium` | `...typography.bodyMedium` | +| `text-xs font-semibold uppercase tracking-wider` | `fontSize: 11, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.8` | +| `text-lg font-semibold` | `...typography.h3` | +| `text-[10px] font-medium` | `...typography.small` | + +### Shadows + +| Tailwind | React Native | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | +| `shadow-sm` | `shadowColor: '#000', shadowOffset: {width:0,height:1}, shadowOpacity: 0.05, shadowRadius: 2, elevation: 1` | +| `shadow-xl` | Higher `shadowOpacity` and `elevation` values | +| `ring-2 ring-ring` | `borderColor: colors.borderFocus, shadowColor: colors.borderFocus, shadowOpacity: 0.25` | + +--- + +## Interaction State Translation + +Web CSS has `:hover`, `:focus`, `:active` pseudo-classes. React Native uses `Pressable` with style functions. + +```tsx +// Webmail (Tailwind) + + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + dialog: { + width: '100%', + maxWidth: 400, + backgroundColor: colors.background, + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.lg, + shadowColor: '#000', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.25, + shadowRadius: 20, + elevation: 10, + }, + content: { + padding: spacing.xxl, // p-6 + }, + iconBadge: { + width: 40, + height: 40, + borderRadius: radius.full, + backgroundColor: colors.errorBg, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.md, + }, + title: { + ...typography.h3, // text-lg font-semibold + color: colors.text, + }, + message: { + ...typography.body, // text-sm + color: colors.mutedForeground, // text-muted-foreground + marginTop: spacing.sm, + }, + footer: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.md, // gap-3 + paddingHorizontal: spacing.xxl, // px-6 + paddingBottom: spacing.xxl, // pb-6 + }, +}); diff --git a/src/components/Input.tsx b/src/components/Input.tsx new file mode 100644 index 00000000..089a40d8 --- /dev/null +++ b/src/components/Input.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { TextInput as RNTextInput, View, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; + +interface InputProps extends TextInputProps { + label?: string; + error?: string; + containerStyle?: ViewStyle; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +} + +/** + * Matches webmail input.tsx: + * - h-10 w-full rounded-md border border-input bg-background + * - px-3 py-2 text-sm text-foreground + * - placeholder:text-muted-foreground + * - hover:border-muted-foreground + * - focus:ring-2 ring-ring border-ring + * - disabled:opacity-50 + */ +export default function Input({ + label, + error, + containerStyle, + leftIcon, + rightIcon, + style, + ...props +}: InputProps) { + const [focused, setFocused] = React.useState(false); + + return ( + + {label && {label}} + + {leftIcon && {leftIcon}} + { setFocused(true); props.onFocus?.(e); }} + onBlur={(e) => { setFocused(false); props.onBlur?.(e); }} + /> + {rightIcon && {rightIcon}} + + {error && {error}} + + ); +} + +const styles = StyleSheet.create({ + label: { + ...typography.bodyMedium, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + height: componentSizes.inputHeight, // h-10 = 40px + borderWidth: 1, + borderColor: colors.border, // border-input + borderRadius: radius.sm, // rounded-md = 6px + backgroundColor: colors.background, // bg-background + paddingHorizontal: spacing.md, // px-3 = 12px + }, + inputFocused: { + borderColor: colors.borderFocus, // border-ring (blue-500) + borderWidth: 2, + }, + inputError: { + borderColor: colors.error, + }, + inputDisabled: { + opacity: 0.5, + }, + input: { + flex: 1, + ...typography.body, // text-sm = 14px + color: colors.text, // text-foreground + paddingVertical: 0, + }, + iconLeft: { + marginRight: spacing.sm, + }, + iconRight: { + marginLeft: spacing.sm, + }, + errorText: { + ...typography.caption, + color: colors.error, + marginTop: spacing.xs, + }, +}); diff --git a/src/components/RadioGroup.tsx b/src/components/RadioGroup.tsx new file mode 100644 index 00000000..91043f1e --- /dev/null +++ b/src/components/RadioGroup.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet, ViewStyle } from 'react-native'; +import { colors, spacing, radius, typography } from '../theme/tokens'; + +interface RadioOption { + label: string; + value: string; +} + +interface RadioGroupProps { + options: RadioOption[]; + value: string; + onChange: (value: string) => void; + style?: ViewStyle; +} + +/** + * Matches webmail RadioGroup (button variant): + * - container: flex gap-1.5 + * - button: px-3 py-1.5 text-xs rounded-md + * - selected: bg-primary text-primary-foreground font-medium + * - unselected: bg-muted text-foreground + */ +export default function RadioGroup({ options, value, onChange, style }: RadioGroupProps) { + return ( + + {options.map((opt) => { + const selected = opt.value === value; + return ( + onChange(opt.value)} + > + + {opt.label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + gap: 6, // gap-1.5 + }, + option: { + paddingHorizontal: spacing.md, // px-3 + paddingVertical: 6, // py-1.5 + borderRadius: radius.sm, // rounded-md + }, + optionSelected: { + backgroundColor: colors.primary, + }, + optionUnselected: { + backgroundColor: colors.muted, + }, + optionText: { + ...typography.caption, // text-xs + }, + textSelected: { + color: colors.primaryForeground, + fontWeight: '500', + }, + textUnselected: { + color: colors.text, + }, +}); diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx new file mode 100644 index 00000000..e28dcb5b --- /dev/null +++ b/src/components/ToggleSwitch.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Pressable, View, StyleSheet } from 'react-native'; +import { colors, radius, componentSizes } from '../theme/tokens'; + +interface ToggleSwitchProps { + value: boolean; + onValueChange: (val: boolean) => void; + disabled?: boolean; +} + +/** + * Matches webmail ToggleSwitch: + * - h-6 w-11 rounded-full + * - checked: bg-primary, unchecked: bg-muted + * - thumb: h-4 w-4 rounded-full bg-background + * - translate-x-6 (checked) / translate-x-1 (unchecked) + */ +export default function ToggleSwitch({ value, onValueChange, disabled = false }: ToggleSwitchProps) { + return ( + !disabled && onValueChange(!value)} + accessibilityRole="switch" + accessibilityState={{ checked: value, disabled }} + > + + + ); +} + +const styles = StyleSheet.create({ + track: { + width: componentSizes.toggleWidth, // w-11 = 44 + height: componentSizes.toggleHeight, // h-6 = 24 + borderRadius: radius.full, + justifyContent: 'center', + }, + trackOn: { + backgroundColor: colors.primary, + }, + trackOff: { + backgroundColor: colors.muted, + }, + thumb: { + width: componentSizes.toggleThumb, // h-4 w-4 = 16 + height: componentSizes.toggleThumb, + borderRadius: radius.full, + backgroundColor: colors.background, + }, + disabled: { + opacity: 0.5, + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 00000000..d55f3b40 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,7 @@ +export { default as Button } from './Button'; +export { default as ToggleSwitch } from './ToggleSwitch'; +export { default as Input } from './Input'; +export { default as Badge } from './Badge'; +export { Card, SectionHeader, SettingItemRow } from './Card'; +export { default as RadioGroup } from './RadioGroup'; +export { default as Dialog } from './Dialog'; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx new file mode 100644 index 00000000..51d3ed3c --- /dev/null +++ b/src/screens/CalendarScreen.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'; +import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react-native'; +import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isToday, addMonths, subMonths } from 'date-fns'; +import { colors } from '../theme/colors'; + +const MOCK_EVENTS = [ + { id: '1', title: 'Team Standup', time: '09:00', color: '#3b82f6' }, + { id: '2', title: 'Design Review', time: '11:00', color: '#8b5cf6' }, + { id: '3', title: 'Lunch with Sarah', time: '12:30', color: '#22c55e' }, + { id: '4', title: 'Sprint Planning', time: '14:00', color: '#f59e0b' }, +]; + +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +function MonthGrid({ currentDate }: { currentDate: Date }) { + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + const calStart = startOfWeek(monthStart); + const calEnd = endOfWeek(monthEnd); + + const days: Date[] = []; + let day = calStart; + while (day <= calEnd) { + days.push(day); + day = addDays(day, 1); + } + + return ( + + + {WEEKDAYS.map((wd) => ( + {wd} + ))} + + + {days.map((d, i) => { + const sameMonth = isSameMonth(d, currentDate); + const today = isToday(d); + return ( + + + + {format(d, 'd')} + + + + ); + })} + + + ); +} + +export default function CalendarScreen() { + const [currentDate, setCurrentDate] = React.useState(new Date()); + + return ( + + + + Calendar + + + + setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}> + + + {format(currentDate, 'MMMM yyyy')} + setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}> + + + + + + + + Today's Events + {MOCK_EVENTS.map((event) => ( + + + + {event.title} + {event.time} + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: 8, + }, + headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text }, + monthNav: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + }, + navBtn: { padding: 8 }, + monthLabel: { fontSize: 17, fontWeight: '600', color: colors.text }, + weekdayRow: { + flexDirection: 'row', + paddingHorizontal: 8, + marginBottom: 4, + }, + weekdayLabel: { + flex: 1, + textAlign: 'center', + fontSize: 12, + fontWeight: '600', + color: colors.textMuted, + }, + daysGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 8, + }, + dayCell: { + width: '14.28%', + alignItems: 'center', + paddingVertical: 6, + }, + dayNumber: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + dayText: { fontSize: 14, color: colors.text }, + dayTextMuted: { color: colors.textMuted }, + todayCircle: { backgroundColor: colors.primary }, + todayText: { color: colors.textInverse, fontWeight: '700' }, + todaySection: { paddingHorizontal: 16, paddingTop: 20 }, + todaySectionTitle: { fontSize: 16, fontWeight: '600', color: colors.text, marginBottom: 12 }, + eventCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: 10, + padding: 12, + marginBottom: 8, + }, + eventDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 }, + eventContent: { flex: 1 }, + eventTitle: { fontSize: 15, fontWeight: '500', color: colors.text }, + eventTime: { fontSize: 13, color: colors.textSecondary, marginTop: 2 }, +}); diff --git a/src/screens/CalendarScreenNew.tsx b/src/screens/CalendarScreenNew.tsx new file mode 100644 index 00000000..de1c6708 --- /dev/null +++ b/src/screens/CalendarScreenNew.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import { View, Text, StyleSheet, ScrollView, Pressable, FlatList } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + ChevronLeft, ChevronRight, Plus, Clock, MapPin, + CalendarDays, LayoutGrid, List as ListIcon, Users +} from 'lucide-react-native'; +import { + format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, + addDays, isSameMonth, isToday, isSameDay, addMonths, subMonths +} from 'date-fns'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; +import { Button, Card, Badge } from '../components'; + +type ViewMode = 'month' | 'week' | 'agenda'; + +const MOCK_EVENTS = [ + { + id: '1', title: 'Team Standup', time: '09:00 – 09:30', + color: colors.calendar.blue, location: 'Zoom', + date: new Date(), allDay: false, participants: 5, + }, + { + id: '2', title: 'Design Review', time: '11:00 – 12:00', + color: colors.calendar.purple, location: 'Conference Room A', + date: new Date(), allDay: false, participants: 3, + }, + { + id: '3', title: 'Lunch with Sarah', time: '12:30 – 13:30', + color: colors.calendar.green, location: 'Café Nero', + date: new Date(), allDay: false, participants: 2, + }, + { + id: '4', title: 'Sprint Planning', time: '14:00 – 15:30', + color: colors.calendar.orange, location: 'Main Office', + date: new Date(), allDay: false, participants: 8, + }, + { + id: '5', title: 'Company Holiday', time: '', + color: colors.calendar.teal, location: '', + date: addDays(new Date(), 2), allDay: true, participants: 0, + }, + { + id: '6', title: 'Release v2.5', time: '10:00 – 11:00', + color: colors.calendar.red, location: '', + date: addDays(new Date(), 1), allDay: false, participants: 4, + }, +]; + +const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + +function MonthGrid({ currentDate, selectedDate, onSelectDate }: { + currentDate: Date; + selectedDate: Date; + onSelectDate: (d: Date) => void; +}) { + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + const calStart = startOfWeek(monthStart); + const calEnd = endOfWeek(monthEnd); + + const days: Date[] = []; + let day = calStart; + while (day <= calEnd) { + days.push(day); + day = addDays(day, 1); + } + + // Count events per day + const eventDots = (d: Date) => MOCK_EVENTS.filter(e => isSameDay(e.date, d)); + + return ( + + + {WEEKDAYS.map((wd, i) => ( + {wd} + ))} + + + {days.map((d, i) => { + const sameMonth = isSameMonth(d, currentDate); + const today = isToday(d); + const selected = isSameDay(d, selectedDate); + const dayEvents = eventDots(d); + + return ( + onSelectDate(d)}> + + + {format(d, 'd')} + + + {dayEvents.length > 0 && ( + + {dayEvents.slice(0, 3).map((e, idx) => ( + + ))} + + )} + + ); + })} + + + ); +} + +function EventCard({ event }: { event: typeof MOCK_EVENTS[0] }) { + return ( + + + + + {event.title} + {event.allDay && ( + + All day + + )} + + {event.time ? ( + + + {event.time} + + ) : null} + {event.location ? ( + + + {event.location} + + ) : null} + {event.participants > 0 && ( + + + {event.participants} participants + + )} + + + ); +} + +interface CalendarScreenProps { + onCreateEvent?: () => void; +} + +export default function CalendarScreen({ onCreateEvent }: CalendarScreenProps) { + const [currentDate, setCurrentDate] = React.useState(new Date()); + const [selectedDate, setSelectedDate] = React.useState(new Date()); + const [viewMode, setViewMode] = React.useState('month'); + + const selectedEvents = MOCK_EVENTS.filter(e => isSameDay(e.date, selectedDate)); + const isSelectedToday = isToday(selectedDate); + + return ( + + {/* Header */} + + + {format(currentDate, 'MMMM yyyy')} + + {isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')} + + + + {/* View mode toggle */} + + {(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => { + const Icon = mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon; + const active = viewMode === mode; + return ( + setViewMode(mode)} + > + + + ); + })} + + + + + + {/* Month navigation */} + + setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}> + + + { setCurrentDate(new Date()); setSelectedDate(new Date()); }} + > + + + setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}> + + + + + + {/* Calendar grid */} + + + {/* Events for selected day */} + + + + {isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')} + + + {selectedEvents.length} event{selectedEvents.length !== 1 ? 's' : ''} + + + {selectedEvents.length > 0 ? ( + selectedEvents.map(event => ) + ) : ( + + + No events + Tap + to create one + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + scrollContent: { paddingBottom: 80 }, + + // Header + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + headerTitle: { ...typography.h3, color: colors.text }, + headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 }, + headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + viewToggle: { + flexDirection: 'row', + backgroundColor: colors.surface, + borderRadius: radius.md, + padding: 2, + }, + viewToggleBtn: { + width: 32, height: 32, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.sm, + }, + viewToggleBtnActive: { backgroundColor: colors.background }, + addButton: { + borderRadius: radius.full, + }, + + // Month nav + monthNav: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.xs, + gap: spacing.lg, + }, + navBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.full }, + + // Calendar grid + calendarGrid: { paddingHorizontal: spacing.sm }, + weekdayRow: { + flexDirection: 'row', + paddingHorizontal: spacing.xs, + marginBottom: 4, + }, + weekdayLabel: { + flex: 1, + textAlign: 'center', + ...typography.small, + color: colors.textMuted, + }, + daysGrid: { flexDirection: 'row', flexWrap: 'wrap' }, + dayCell: { + width: '14.28%', + alignItems: 'center', + paddingVertical: 4, + }, + dayNumber: { + width: 36, height: 36, + borderRadius: 18, + alignItems: 'center', justifyContent: 'center', + }, + dayText: { ...typography.body, color: colors.text }, + dayTextMuted: { color: colors.textMuted }, + todayCircle: { borderWidth: 2, borderColor: colors.primary, backgroundColor: 'transparent' }, + todayText: { color: colors.textInverse, fontWeight: '700' }, + selectedCircle: { backgroundColor: colors.primaryBg, borderWidth: 1.5, borderColor: colors.primary }, + selectedText: { color: colors.primary, fontWeight: '600' }, + eventDotsRow: { + flexDirection: 'row', + gap: 2, + marginTop: 2, + height: componentSizes.eventDot, + }, + eventDotSmall: { width: componentSizes.eventDot, height: componentSizes.eventDot, borderRadius: 3 }, + + // Events section + eventsSection: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.xl, + }, + eventsSectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + eventsSectionTitle: { ...typography.bodyMedium, color: colors.text }, + eventsSectionCount: { ...typography.caption, color: colors.textMuted }, + + // Event card — matches webmail card styling + eventCard: { + flexDirection: 'row', + backgroundColor: colors.card, + borderRadius: radius.lg, + marginBottom: spacing.sm, + overflow: 'hidden', + borderWidth: 1, + borderColor: colors.border, + }, + eventColorBar: { width: 3 }, + + eventBody: { + flex: 1, + padding: spacing.md, + gap: 4, + }, + eventHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + eventTitle: { ...typography.bodyMedium, color: colors.text, flex: 1 }, + allDayBadge: { + backgroundColor: colors.primaryBg, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: radius.full, + }, + allDayText: { ...typography.small, color: colors.primary }, + eventDetail: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + eventDetailText: { ...typography.caption, color: colors.textMuted }, + + // Empty state + emptyState: { + alignItems: 'center', + paddingVertical: spacing.xxxl, + gap: spacing.sm, + }, + emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary }, + emptySubtitle: { ...typography.caption, color: colors.textMuted }, +}); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx new file mode 100644 index 00000000..a71124ce --- /dev/null +++ b/src/screens/ComposeScreen.tsx @@ -0,0 +1,329 @@ +import React from 'react'; +import { View, Text, StyleSheet, TextInput, Pressable, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + X, Send, Paperclip, ChevronDown, Bold, Italic, List, + Link2, Image, MoreHorizontal, Minus +} from 'lucide-react-native'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; +import { Button } from '../components'; + +interface Recipient { + name: string; + email: string; +} + +interface ComposeScreenProps { + onClose?: () => void; + onSend?: () => void; + replyTo?: { from: Recipient; subject: string; body?: string }; +} + +function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove: () => void }) { + return ( + + + {recipient.name || recipient.email} + + + + + + ); +} + +export default function ComposeScreen({ onClose, onSend, replyTo }: ComposeScreenProps) { + const [toRecipients, setToRecipients] = React.useState( + replyTo ? [replyTo.from] : [] + ); + const [ccVisible, setCcVisible] = React.useState(false); + const [ccRecipients, setCcRecipients] = React.useState([]); + const [subject, setSubject] = React.useState( + replyTo ? `Re: ${replyTo.subject}` : '' + ); + const [body, setBody] = React.useState(''); + const [toInput, setToInput] = React.useState(''); + const [ccInput, setCcInput] = React.useState(''); + + const canSend = toRecipients.length > 0 && subject.trim().length > 0; + + return ( + + {/* Header */} + + + + + + {replyTo ? 'Reply' : 'New Message'} + + + + + + + + + + + + {/* From */} + + From + + user@bulwark.mail + + + + + {/* To */} + + To + + {toRecipients.map((r, i) => ( + setToRecipients(prev => prev.filter((_, idx) => idx !== i))} + /> + ))} + + + {!ccVisible && ( + setCcVisible(true)} style={styles.ccToggle}> + Cc + + )} + + + {/* Cc (expandable) */} + {ccVisible && ( + + Cc + + {ccRecipients.map((r, i) => ( + setCcRecipients(prev => prev.filter((_, idx) => idx !== i))} + /> + ))} + + + + )} + + {/* Subject */} + + Subject + + + + {/* Body */} + + + {/* Quote (if replying) */} + {replyTo?.body && ( + + + + + On {new Date().toLocaleDateString()}, {replyTo.from.name} wrote: + + + {replyTo.body} + + + + )} + + + {/* Formatting toolbar */} + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + flex: { flex: 1 }, + + // Header + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + headerBtn: { + width: componentSizes.avatarSm, height: componentSizes.avatarSm, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.full, + }, + headerTitle: { ...typography.bodyMedium, color: colors.text, flex: 1, marginLeft: spacing.sm }, + headerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs }, + sendButtonDisabled: { backgroundColor: colors.surfaceActive }, + + // Fields + fieldRow: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + minHeight: 44, + }, + fieldLabel: { + ...typography.caption, + color: colors.textMuted, + width: 52, + paddingTop: spacing.xs, + }, + fieldContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + fromText: { ...typography.body, color: colors.text }, + recipientField: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 4, + }, + recipientInput: { + flex: 1, + ...typography.body, + color: colors.text, + minWidth: 100, + paddingVertical: 4, + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + backgroundColor: colors.primaryBg, + borderRadius: radius.full, + paddingHorizontal: spacing.md, + paddingVertical: 4, + maxWidth: 200, + }, + chipText: { ...typography.caption, color: colors.primary }, + ccToggle: { paddingHorizontal: spacing.sm, paddingVertical: 4 }, + ccToggleText: { ...typography.captionMedium, color: colors.primary }, + subjectInput: { flex: 1, ...typography.bodyMedium, color: colors.text }, + + + // Body + bodyInput: { + ...typography.body, + color: colors.text, + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.xl, + minHeight: 200, + lineHeight: 22, + }, + + // Quote + quoteBlock: { + flexDirection: 'row', + marginHorizontal: spacing.lg, + marginBottom: spacing.xl, + }, + quoteBorder: { + width: 2, + backgroundColor: colors.border, + borderRadius: 2, + marginRight: spacing.md, + }, + quoteContent: { flex: 1 }, + quoteMeta: { ...typography.small, color: colors.textMuted, marginBottom: spacing.xs }, + quoteText: { ...typography.caption, color: colors.textSecondary, lineHeight: 18 }, + + // Format bar + formatBar: { + borderTopWidth: 1, + borderTopColor: colors.borderLight, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + formatActions: { + flexDirection: 'row', + gap: spacing.xs, + }, + formatBtn: { + width: componentSizes.avatarSm, height: componentSizes.avatarSm, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.sm, + }, +}); diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx new file mode 100644 index 00000000..b2eb44c3 --- /dev/null +++ b/src/screens/ContactsScreen.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { View, Text, StyleSheet, SectionList, Pressable } from 'react-native'; +import { Users } from 'lucide-react-native'; +import { colors } from '../theme/colors'; + +const MOCK_CONTACTS = [ + { id: '1', name: 'Alice Johnson', email: 'alice@example.com', phone: '+1 555-0101' }, + { id: '2', name: 'Bob Smith', email: 'bob@example.com', phone: '+1 555-0102' }, + { id: '3', name: 'Carol Davis', email: 'carol@example.com', phone: '+1 555-0103' }, + { id: '4', name: 'David Wilson', email: 'david@example.com', phone: '+1 555-0104' }, + { id: '5', name: 'Emma Brown', email: 'emma@example.com', phone: '+1 555-0105' }, + { id: '6', name: 'Frank Miller', email: 'frank@example.com', phone: '+1 555-0106' }, + { id: '7', name: 'Grace Lee', email: 'grace@example.com', phone: '+1 555-0107' }, + { id: '8', name: 'Henry Taylor', email: 'henry@example.com', phone: '+1 555-0108' }, +]; + +function groupByLetter(contacts: typeof MOCK_CONTACTS) { + const groups: Record = {}; + contacts.forEach((c) => { + const letter = c.name.charAt(0).toUpperCase(); + if (!groups[letter]) groups[letter] = []; + groups[letter].push(c); + }); + return Object.keys(groups) + .sort() + .map((letter) => ({ title: letter, data: groups[letter] })); +} + +function ContactRow({ item }: { item: typeof MOCK_CONTACTS[0] }) { + return ( + [styles.contactRow, pressed && styles.contactRowPressed]}> + + {item.name.charAt(0)} + + + {item.name} + {item.email} + + + ); +} + +export default function ContactsScreen() { + const sections = React.useMemo(() => groupByLetter(MOCK_CONTACTS), []); + + return ( + + + + Contacts + {MOCK_CONTACTS.length} + + item.id} + renderItem={({ item }) => } + renderSectionHeader={({ section: { title } }) => ( + + {title} + + )} + ItemSeparatorComponent={() => } + contentContainerStyle={styles.listContent} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: 8, + }, + headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 }, + headerCount: { fontSize: 13, color: colors.textSecondary }, + listContent: { paddingBottom: 20 }, + sectionHeader: { + backgroundColor: colors.surface, + paddingHorizontal: 16, + paddingVertical: 6, + }, + sectionTitle: { fontSize: 13, fontWeight: '600', color: colors.textSecondary }, + contactRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + }, + contactRowPressed: { backgroundColor: colors.surfaceHover }, + avatar: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primaryLight, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + avatarText: { fontSize: 16, fontWeight: '600', color: colors.textInverse }, + contactInfo: { flex: 1 }, + contactName: { fontSize: 15, fontWeight: '500', color: colors.text }, + contactEmail: { fontSize: 13, color: colors.textSecondary, marginTop: 2 }, + separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 }, +}); diff --git a/src/screens/ContactsScreenNew.tsx b/src/screens/ContactsScreenNew.tsx new file mode 100644 index 00000000..6be28414 --- /dev/null +++ b/src/screens/ContactsScreenNew.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { View, Text, StyleSheet, SectionList, Pressable, TextInput } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Search, Plus, Phone, Mail, ChevronRight, UserCircle, X +} from 'lucide-react-native'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; +import { Button } from '../components'; + +interface Contact { + id: string; + name: string; + email: string; + phone?: string; + company?: string; + avatar?: string; +} + +const MOCK_CONTACTS: Contact[] = [ + { id: '1', name: 'Alice Johnson', email: 'alice@example.com', phone: '+1 555-0101', company: 'Acme Inc' }, + { id: '2', name: 'Bob Smith', email: 'bob@company.org', phone: '+1 555-0102', company: 'TechCorp' }, + { id: '3', name: 'Carol Davis', email: 'carol@billing.co', company: 'Billing Co' }, + { id: '4', name: 'David Wilson', email: 'david@startup.io', phone: '+1 555-0104' }, + { id: '5', name: 'Emma Brown', email: 'emma@hr.org', phone: '+1 555-0105', company: 'HR Solutions' }, + { id: '6', name: 'Frank Miller', email: 'frank@dev.team', company: 'DevTeam' }, + { id: '7', name: 'Grace Lee', email: 'grace@newsletter.com', phone: '+1 555-0107' }, + { id: '8', name: 'Henry Taylor', email: 'henry@dev.team', phone: '+1 555-0108', company: 'DevTeam' }, + { id: '9', name: 'Iris Chen', email: 'iris@design.co', company: 'Design Studio' }, + { id: '10', name: 'Jack Brown', email: 'jack@sales.io', phone: '+1 555-0110' }, + { id: '11', name: 'Karen White', email: 'karen@ops.co', phone: '+1 555-0111', company: 'OpsCo' }, + { id: '12', name: 'Leo Garcia', email: 'leo@eng.dev', company: 'Engineering' }, +]; + +function groupContacts(contacts: Contact[]) { + const groups: Record = {}; + contacts.forEach(c => { + const letter = c.name.charAt(0).toUpperCase(); + if (!groups[letter]) groups[letter] = []; + groups[letter].push(c); + }); + return Object.keys(groups).sort().map(letter => ({ + title: letter, + data: groups[letter].sort((a, b) => a.name.localeCompare(b.name)), + })); +} + +function ContactRow({ contact, onPress }: { contact: Contact; onPress: () => void }) { + const initials = contact.name.split(' ').map(n => n[0]).join('').slice(0, 2); + + return ( + [styles.contactRow, pressed && styles.contactRowPressed]} + onPress={onPress} + > + + {initials} + + + {contact.name} + {contact.email} + {contact.company && ( + {contact.company} + )} + + + {contact.phone && ( + + + + )} + + + + + + ); +} + +interface ContactsScreenProps { + onContactPress?: (contact: Contact) => void; + onCreateContact?: () => void; +} + +export default function ContactsScreen({ onContactPress, onCreateContact }: ContactsScreenProps) { + const [searchQuery, setSearchQuery] = React.useState(''); + const [searchActive, setSearchActive] = React.useState(false); + + const filtered = searchQuery + ? MOCK_CONTACTS.filter(c => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.email.toLowerCase().includes(searchQuery.toLowerCase()) || + (c.company && c.company.toLowerCase().includes(searchQuery.toLowerCase())) + ) + : MOCK_CONTACTS; + + const sections = groupContacts(filtered); + + return ( + + {/* Header */} + + Contacts + {MOCK_CONTACTS.length} + + setSearchActive(!searchActive)} + style={styles.headerBtn} + > + + + + + + + {/* Search */} + {searchActive && ( + + + + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + )} + + {/* Contact list */} + item.id} + renderItem={({ item }) => ( + onContactPress?.(item)} /> + )} + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + ItemSeparatorComponent={() => } + stickySectionHeadersEnabled + contentContainerStyle={styles.listContent} + ListEmptyComponent={ + + + No contacts found + Try a different search + + } + /> + + {/* Alphabet index */} + + {sections.map(s => ( + {s.title} + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + listContent: { paddingBottom: 80 }, + + // Header + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + gap: spacing.sm, + }, + headerTitle: { ...typography.h3, color: colors.text }, + headerCount: { + ...typography.small, + color: colors.textMuted, + backgroundColor: colors.surface, + borderRadius: radius.full, + paddingHorizontal: 8, + paddingVertical: 2, + overflow: 'hidden', + }, + headerActions: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: spacing.sm }, + headerBtn: { + width: 40, height: 40, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.full, + }, + addButton: { + borderRadius: radius.full, + }, + + // Search — matches webmail input styling + searchBar: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: spacing.lg, + marginBottom: spacing.md, + paddingHorizontal: spacing.md, + height: componentSizes.inputHeight, + backgroundColor: colors.background, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: colors.border, + gap: spacing.sm, + }, + searchInput: { flex: 1, ...typography.body, color: colors.text }, + + // Section header + sectionHeader: { + backgroundColor: colors.background, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.xs, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + sectionHeaderText: { ...typography.small, color: colors.primary }, + + // Contact row + contactRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + contactRowPressed: { backgroundColor: colors.surfaceHover }, + contactAvatar: { + width: componentSizes.avatarMd, height: componentSizes.avatarMd, + borderRadius: componentSizes.avatarMd / 2, + backgroundColor: colors.primaryBg, + alignItems: 'center', justifyContent: 'center', + marginRight: spacing.md, + }, + contactAvatarText: { ...typography.bodyMedium, color: colors.primary }, + contactInfo: { flex: 1 }, + contactName: { ...typography.bodyMedium, color: colors.text }, + contactEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 }, + contactCompany: { ...typography.small, color: colors.textSecondary, marginTop: 1 }, + contactActions: { flexDirection: 'row', gap: spacing.sm }, + contactActionBtn: { + width: 32, height: 32, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.full, + backgroundColor: colors.primaryBg, + }, + separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 }, + + // Alphabet index + alphabetIndex: { + position: 'absolute', + right: 2, + top: '20%', + alignItems: 'center', + paddingVertical: spacing.sm, + }, + alphabetLetter: { + ...typography.small, + color: colors.primary, + paddingVertical: 1, + paddingHorizontal: 4, + }, + + // Empty state + emptyState: { + alignItems: 'center', + paddingVertical: spacing.xxxl * 2, + gap: spacing.sm, + }, + emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary }, + emptySubtitle: { ...typography.caption, color: colors.textMuted }, +}); diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx new file mode 100644 index 00000000..1f81bf73 --- /dev/null +++ b/src/screens/EmailListScreen.tsx @@ -0,0 +1,389 @@ +import React from 'react'; +import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Search, SquarePen, Menu, Filter, Square, + Star, Paperclip, MessageSquare, XCircle, Circle, Reply, Forward, +} from 'lucide-react-native'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; +import { Button } from '../components'; +import { useEmailStore } from '../stores/email-store'; +import type { Email } from '../api/types'; + +// Avatar color palette — deterministic based on initials hash +const AVATAR_PALETTE = [ + '#14b8a6', '#a855f7', '#f97316', '#22c55e', '#3b82f6', + '#ef4444', '#eab308', '#ec4899', '#6366f1', '#06b6d4', +]; + +function getInitials(name: string): string { + // Handle multi-person names like "Sophie Müller, Lars Johansson" + const first = name.split(',')[0].trim(); + const parts = first.split(' '); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return first.substring(0, 2).toUpperCase(); +} + +function getAvatarColor(initials: string): string { + let hash = 0; + for (let i = 0; i < initials.length; i++) hash = initials.charCodeAt(i) + ((hash << 5) - hash); + return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]; +} + +function getSenderName(email: Email): string { + return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown'; +} + +function isUnread(email: Email): boolean { + return !email.keywords?.$seen; +} + +function isStarred(email: Email): boolean { + return !!email.keywords?.$flagged; +} + +function formatRelativeTime(date: Date): string { + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMin = Math.floor(diffMs / (1000 * 60)); + const diffHr = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMin < 1) return 'now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDay === 1) return 'Yesterday'; + if (diffDay < 7) return `${diffDay}d ago`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function EmailRow({ item, onPress }: { item: Email; onPress: () => void }) { + const senderName = getSenderName(item); + const initials = getInitials(senderName); + const avatarColor = getAvatarColor(initials); + const unread = isUnread(item); + const starred = isStarred(item); + + return ( + [styles.emailRow, pressed && styles.emailRowPressed]} + onPress={onPress} + > + {/* Avatar */} + + {initials} + + + {/* Content */} + + {/* Row 1: Sender + indicators + time */} + + + + {senderName} + + {starred && ( + + )} + {item.hasAttachment && ( + + )} + + + {formatRelativeTime(new Date(item.receivedAt))} + + + + {/* Row 2: Subject */} + + + {item.subject || '(no subject)'} + + + + {/* Row 3: Preview */} + + {item.preview} + + + + ); +} + +interface EmailListScreenProps { + onEmailPress?: (email: Email) => void; + onComposePress?: () => void; + onDrawerOpen?: () => void; +} + +export default function EmailListScreen({ onEmailPress, onComposePress, onDrawerOpen }: EmailListScreenProps) { + const [searchQuery, setSearchQuery] = React.useState(''); + const emails = useEmailStore((s) => s.emails); + const mailboxes = useEmailStore((s) => s.mailboxes); + const loading = useEmailStore((s) => s.loading); + const currentMailboxId = useEmailStore((s) => s.currentMailboxId); + const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); + const selectMailbox = useEmailStore((s) => s.selectMailbox); + const loadMoreEmails = useEmailStore((s) => s.loadMoreEmails); + const refreshEmails = useEmailStore((s) => s.refreshEmails); + + // Load mailboxes and select inbox on mount + React.useEffect(() => { + if (mailboxes.length === 0) { + void fetchMailboxes(); + } + }, [fetchMailboxes, mailboxes.length]); + + React.useEffect(() => { + if (mailboxes.length > 0 && !currentMailboxId) { + const inbox = mailboxes.find((m) => m.role === 'inbox') || mailboxes[0]; + void selectMailbox(inbox.id); + } + }, [mailboxes, currentMailboxId, selectMailbox]); + + return ( + + {/* Header */} + + + + + Inbox + + + + + {/* Search bar (always visible) */} + + + + + + + + + + + + + + {/* Email list */} + {loading && emails.length === 0 ? ( + + + Loading emails... + + ) : emails.length === 0 ? ( + + No emails + + ) : ( + item.id} + renderItem={({ item }) => ( + onEmailPress?.(item)} /> + )} + ItemSeparatorComponent={() => } + contentContainerStyle={styles.listContent} + onEndReached={() => { void loadMoreEmails(); }} + onEndReachedThreshold={0.3} + refreshing={loading} + onRefresh={() => { void refreshEmails(); }} + /> + )} + + {/* Compose FAB — matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */} + [styles.fab, pressed && styles.fabPressed]} + > + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + + // Header — matches web mobile-header: h-14 (56px), px-4, border-b + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + height: componentSizes.headerHeight, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: spacing.md, + }, + headerButton: { + width: componentSizes.buttonLg, height: componentSizes.buttonLg, // h-11 w-11 = 44px + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.full, + }, + headerTitle: { ...typography.h3, color: colors.text, flex: 1 }, // text-lg font-semibold + headerLogo: { + width: 28, + height: 28, + }, + + // Search — matches web search bar styling + searchBar: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + gap: spacing.sm, + }, + checkboxButton: { + width: componentSizes.buttonMd, height: componentSizes.buttonMd, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.xs, + }, + searchInputArea: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + height: componentSizes.inputHeight, // h-10 = 40px + backgroundColor: colors.surface, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: colors.border, + gap: spacing.sm, + }, + searchInput: { flex: 1, ...typography.body, color: colors.text }, + filterButton: { + width: componentSizes.buttonMd, height: componentSizes.buttonMd, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.xs, + }, + + // Email list — matches web email-list-item + listContent: { paddingBottom: 100 }, + emailRow: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, // px-4 = 16px + paddingVertical: spacing.md, // py-3 = 12px (density-item-py) + gap: spacing.md, // gap-3 = 12px (density-item-gap) + borderBottomWidth: 1, + borderBottomColor: colors.border, // border-b border-border + }, + emailRowPressed: { backgroundColor: colors.surface }, + // Avatar: md = 40px (w-10 h-10), shadow-sm + avatar: { + width: componentSizes.avatarMd, + height: componentSizes.avatarMd, + borderRadius: componentSizes.avatarMd / 2, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 1, + elevation: 1, + }, + avatarText: { fontSize: 16, fontWeight: '700', color: '#ffffff' }, // font-bold text-white + emailContent: { flex: 1, minWidth: 0 }, + emailHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 2, + }, + senderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + flex: 1, + marginRight: spacing.sm, + }, + // Sender: text-sm, font-medium (read) / font-bold (unread) + emailFrom: { ...typography.bodyMedium, color: colors.textSecondary, flexShrink: 1 }, + timeAndTag: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + threadBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 3, + }, + threadBadgeText: { ...typography.caption, color: colors.textMuted }, + // Date: text-xs (12px), tabular-nums + emailDate: { ...typography.caption, color: colors.textMuted }, + subjectRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 2, + }, + // Subject: text-sm (14px), font-semibold (unread) / font-normal (read) + emailSubject: { ...typography.body, color: colors.text, flex: 1 }, + // Preview: text-sm, leading-relaxed, line-clamp-2 + emailPreview: { ...typography.body, color: colors.textSecondary, lineHeight: 20, opacity: 0.8 }, + // Unread state: text foreground + font-bold + textUnread: { fontWeight: '600', color: colors.text }, + textBold: { fontWeight: '700' }, + // Tag pill: text-[10px], rounded-full, px-1.5 py-0.5, gap-1 + tagPill: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 6, // px-1.5 + paddingVertical: 2, // py-0.5 + borderRadius: radius.full, + gap: 4, // gap-1 + }, + tagDot: { + width: componentSizes.tagDot, + height: componentSizes.tagDot, + borderRadius: componentSizes.tagDot / 2, + }, + tagText: { ...typography.small, fontWeight: '500' }, + separator: { height: 0 }, // borders are on rows now + + // Compose FAB — matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg + fab: { + position: 'absolute', + right: spacing.lg, // right-4 + bottom: spacing.lg, // bottom-4 + width: componentSizes.fab, // 56px (h-14) + height: componentSizes.fab, // 56px (w-14) + borderRadius: radius.full, // rounded-full (circle) + backgroundColor: colors.primary, // bg-primary (#3b82f6) + alignItems: 'center', + justifyContent: 'center', + elevation: 6, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 4.65, + zIndex: 40, // z-40 + }, + fabPressed: { + opacity: 0.9, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.sm, + }, + loadingText: { + ...typography.body, + color: colors.textMuted, + }, +}); + +export type { EmailListScreenProps }; diff --git a/src/screens/EmailScreen.tsx b/src/screens/EmailScreen.tsx new file mode 100644 index 00000000..1e82cb49 --- /dev/null +++ b/src/screens/EmailScreen.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { View, Text, StyleSheet, FlatList, Pressable } from 'react-native'; +import { Mail, Star } from 'lucide-react-native'; +import { colors } from '../theme/colors'; + +const MOCK_EMAILS = [ + { id: '1', from: 'Alice Johnson', subject: 'Project update for Q2', preview: 'Hi, I wanted to share the latest progress on our Q2 deliverables...', date: '10:30 AM', unread: true, starred: true }, + { id: '2', from: 'Bob Smith', subject: 'Re: Meeting tomorrow', preview: 'Sounds good! I\'ll bring the presentation slides...', date: '9:15 AM', unread: true, starred: false }, + { id: '3', from: 'Carol Davis', subject: 'Invoice #1234', preview: 'Please find attached the invoice for March services...', date: 'Yesterday', unread: false, starred: false }, + { id: '4', from: 'David Wilson', subject: 'Welcome to the team!', preview: 'We\'re excited to have you on board. Here are some resources...', date: 'Yesterday', unread: false, starred: true }, + { id: '5', from: 'Emma Brown', subject: 'Vacation request', preview: 'I\'d like to request time off from April 15-22...', date: 'Mar 27', unread: false, starred: false }, + { id: '6', from: 'Frank Miller', subject: 'Security alert', preview: 'We detected a new sign-in to your account from...', date: 'Mar 26', unread: false, starred: false }, + { id: '7', from: 'Grace Lee', subject: 'Newsletter: March Highlights', preview: 'Here\'s what happened this month in our community...', date: 'Mar 25', unread: false, starred: false }, + { id: '8', from: 'Henry Taylor', subject: 'Re: Code review', preview: 'Great work on the refactor! I left a few comments...', date: 'Mar 24', unread: false, starred: false }, +]; + +function EmailRow({ item }: { item: typeof MOCK_EMAILS[0] }) { + return ( + [styles.emailRow, pressed && styles.emailRowPressed]}> + + + + {item.from.charAt(0)} + + + + + + + {item.from} + + {item.date} + + + {item.subject} + + + {item.preview} + + + {item.starred && ( + + )} + + ); +} + +export default function EmailScreen() { + return ( + + + + Inbox + 2 unread + + item.id} + renderItem={({ item }) => } + ItemSeparatorComponent={() => } + contentContainerStyle={styles.listContent} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: 8, + }, + headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 }, + headerCount: { fontSize: 13, color: colors.textSecondary }, + listContent: { paddingBottom: 20 }, + emailRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + }, + emailRowPressed: { backgroundColor: colors.surfaceHover }, + emailLeft: { marginRight: 12 }, + avatar: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { fontSize: 16, fontWeight: '600' }, + emailContent: { flex: 1 }, + emailHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }, + emailFrom: { fontSize: 15, color: colors.text, flex: 1, marginRight: 8 }, + emailDate: { fontSize: 12, color: colors.textMuted }, + emailSubject: { fontSize: 14, color: colors.text, marginBottom: 2 }, + emailPreview: { fontSize: 13, color: colors.textSecondary }, + emailUnread: { fontWeight: '700' }, + separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 }, + starIcon: { marginLeft: 8 }, +}); diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx new file mode 100644 index 00000000..a20bd6e0 --- /dev/null +++ b/src/screens/EmailThreadScreen.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import { View, Text, StyleSheet, ScrollView, Pressable, Image } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + ArrowLeft, Star, Archive, Trash2, MoreVertical, Reply, ReplyAll, Forward, + Paperclip, Download, Shield, ChevronDown, ChevronUp, Tag +} from 'lucide-react-native'; + import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; +import { Button } from '../components'; + +interface ThreadMessage { + id: string; + from: { name: string; email: string }; + to: { name: string; email: string }[]; + cc?: { name: string; email: string }[]; + date: Date; + bodyText: string; + attachments?: { name: string; size: string; type: string }[]; + isCollapsed?: boolean; +} + +const MOCK_THREAD: ThreadMessage[] = [ + { + id: 'm1', + from: { name: 'Alice Johnson', email: 'alice@example.com' }, + to: [{ name: 'You', email: 'user@bulwark.mail' }], + date: new Date(2026, 2, 28, 14, 0), + bodyText: 'Hi team,\n\nI wanted to share the latest progress on our Q2 deliverables.\n\nThe frontend refactor is 80% complete and we\'re on track for the April deadline. The new component library is looking great and performance benchmarks are promising.\n\nKey highlights:\n• Email client performance improved by 40%\n• Calendar rendering optimized for large event sets\n• Mobile responsive layout overhaul complete\n\nPlease review the attached timeline and let me know if you have any concerns.', + attachments: [ + { name: 'Q2-Timeline.pdf', size: '2.4 MB', type: 'pdf' }, + { name: 'Benchmarks.xlsx', size: '845 KB', type: 'xlsx' }, + ], + isCollapsed: true, + }, + { + id: 'm2', + from: { name: 'Bob Smith', email: 'bob@company.org' }, + to: [{ name: 'Alice Johnson', email: 'alice@example.com' }, { name: 'You', email: 'user@bulwark.mail' }], + date: new Date(2026, 2, 29, 9, 15), + bodyText: 'Great progress, Alice!\n\nThe benchmarks look impressive. I have a couple questions:\n\n1. Have we tested the calendar on mobile devices with 500+ events?\n2. What\'s the plan for S/MIME integration in the new architecture?\n\nI\'ll circle back after reviewing the full timeline.', + isCollapsed: true, + }, + { + id: 'm3', + from: { name: 'Alice Johnson', email: 'alice@example.com' }, + to: [ + { name: 'Bob Smith', email: 'bob@company.org' }, + { name: 'You', email: 'user@bulwark.mail' }, + ], + cc: [{ name: 'Carol Davis', email: 'carol@billing.co' }], + date: new Date(2026, 2, 29, 10, 30), + bodyText: 'Hi Bob,\n\nGreat questions!\n\n1. Yes — we tested with up to 2,000 events on Pixel 7 and iPhone 14. Scrolling stays at 60fps after the virtualization rewrite. See the attached benchmark for details.\n\n2. S/MIME is Phase 2 (May timeline). We\'re reusing the existing cert management from the web client. The crypto layer is protocol-agnostic so it ports cleanly.\n\nI\'ve CC\'d Carol to keep finance in the loop on the timeline.\n\nBest,\nAlice', + attachments: [ + { name: 'Mobile-Benchmarks.pdf', size: '1.1 MB', type: 'pdf' }, + ], + }, +]; + +function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' + }); +} + +function getFileTypeColor(type: string): string { + switch (type) { + case 'pdf': return colors.error; + case 'xlsx': return colors.calendar.green; + case 'doc': return colors.primary; + default: return colors.textMuted; + } +} + +function MessageCard({ message, isLast }: { message: ThreadMessage; isLast: boolean }) { + const [collapsed, setCollapsed] = React.useState(message.isCollapsed ?? false); + + return ( + + {/* Message header */} + setCollapsed(!collapsed)}> + + + {message.from.name.charAt(0)} + + + + + {message.from.name} + {formatDate(message.date)} + + + to {message.to.map(t => t.name === 'You' ? 'me' : t.name.split(' ')[0]).join(', ')} + {message.cc ? `, cc: ${message.cc.map(c => c.name.split(' ')[0]).join(', ')}` : ''} + + + {collapsed ? ( + + ) : ( + + )} + + + {/* Message body (collapsible) */} + {!collapsed && ( + + {message.bodyText} + + {/* Attachments */} + {message.attachments && message.attachments.length > 0 && ( + + + + + {message.attachments.length} attachment{message.attachments.length > 1 ? 's' : ''} + + + {message.attachments.map((att, idx) => ( + + + + {att.type.toUpperCase()} + + + + {att.name} + {att.size} + + + + ))} + + )} + + {/* Quick reply actions (only on last message) */} + {isLast && ( + + + + + + )} + + )} + + ); +} + +interface EmailThreadScreenProps { + subject?: string; + onBack?: () => void; +} + +export default function EmailThreadScreen({ subject, onBack }: EmailThreadScreenProps) { + return ( + + {/* Toolbar */} + + + + + + + + + + + + + + + + + + + + + + {/* Subject */} + + + {subject || 'Project update for Q2 — milestones and timeline'} + + + + + Work + + {MOCK_THREAD.length} messages + + + + {/* Messages */} + {MOCK_THREAD.map((msg, index) => ( + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + + // Toolbar + toolbar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + toolbarBtn: { + width: componentSizes.avatarSm, height: componentSizes.avatarSm, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.full, + }, + toolbarActions: { flexDirection: 'row', gap: spacing.xs }, + + scrollContent: { flex: 1 }, + scrollContainer: { paddingBottom: 40 }, + + // Subject + subjectArea: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.xxl, + paddingBottom: spacing.md, + }, + subjectText: { ...typography.h2, color: colors.text, lineHeight: 28 }, + subjectMeta: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginTop: spacing.sm }, + labelBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: radius.full, + backgroundColor: colors.tags.blue.bg, + }, + labelText: { ...typography.small }, + messageCount: { ...typography.caption, color: colors.textMuted }, + + // Message card — matches webmail card component + messageCard: { + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + backgroundColor: colors.card, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.border, + overflow: 'hidden', + }, + messageCardLast: { + borderColor: colors.primaryBorder, + }, + messageHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.md, + gap: spacing.sm, + }, + messageAvatar: { + width: 36, height: 36, + borderRadius: 18, + alignItems: 'center', justifyContent: 'center', + }, + messageAvatarText: { fontSize: typography.body.fontSize, fontWeight: '600' }, + messageHeaderContent: { flex: 1 }, + messageHeaderRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + messageFromName: { ...typography.bodyMedium, color: colors.text, flex: 1 }, + messageDate: { ...typography.small, color: colors.textMuted }, + messageToLine: { ...typography.small, color: colors.textMuted }, + + // Message body + messageBody: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + }, + messageBodyText: { + ...typography.base, + color: colors.text, + lineHeight: 24, + }, + + // Attachments + attachmentSection: { + marginTop: spacing.lg, + borderRadius: radius.md, + backgroundColor: colors.surfaceHover, + overflow: 'hidden', + }, + attachmentHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + attachmentHeaderText: { ...typography.small, color: colors.textMuted }, + attachmentRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + gap: spacing.sm, + }, + attachmentIcon: { + width: 36, height: 36, + borderRadius: radius.sm, + alignItems: 'center', justifyContent: 'center', + }, + attachmentIconText: { fontSize: 10, fontWeight: '700' }, + attachmentInfo: { flex: 1 }, + attachmentName: { ...typography.caption, color: colors.text }, + attachmentSize: { ...typography.small, color: colors.textMuted }, + + // Quick actions — using outline buttons from webmail + quickActions: { + flexDirection: 'row', + marginTop: spacing.lg, + gap: spacing.sm, + }, +}); diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx new file mode 100644 index 00000000..e1faf2c9 --- /dev/null +++ b/src/screens/FilesScreen.tsx @@ -0,0 +1,173 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { FileText, Folder, HardDrive } from 'lucide-react-native'; +import { getFileNodes } from '../api/files'; +import type { FileNode } from '../api/types'; +import { colors, spacing, typography } from '../theme/tokens'; + +function formatFileSize(bytes?: number): string { + if (bytes == null) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function FilesScreen() { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadFiles = useCallback(async () => { + setLoading(true); + setError(null); + try { + const nodes = await getFileNodes(null); + setFiles(nodes); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load files'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadFiles(); + }, [loadFiles]); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + Retry + + + ); + } + + if (files.length === 0) { + return ( + + + Files + + + + No files yet + + + ); + } + + return ( + + + Files + + item.id} + renderItem={({ item }) => ( + + {item.type === 'directory' ? ( + + ) : ( + + )} + + + {item.name} + + {item.size != null && ( + {formatFileSize(item.size)} + )} + + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + paddingTop: 60, + paddingHorizontal: spacing.md, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + title: { + fontSize: typography.h2.fontSize, + fontWeight: '700' as const, + color: colors.text, + }, + center: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.sm, + }, + emptyText: { + color: colors.textMuted, + fontSize: typography.body.fontSize, + marginTop: spacing.sm, + }, + errorText: { + color: colors.error, + fontSize: typography.body.fontSize, + textAlign: 'center', + paddingHorizontal: spacing.lg, + }, + retryButton: { + marginTop: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.primary, + borderRadius: 8, + }, + retryText: { + color: colors.primaryForeground, + fontWeight: '600' as const, + }, + fileRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: spacing.sm, + }, + fileInfo: { + flex: 1, + }, + fileName: { + fontSize: typography.body.fontSize, + color: colors.text, + }, + fileSize: { + fontSize: typography.caption.fontSize, + color: colors.textMuted, + marginTop: 2, + }, +}); diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx new file mode 100644 index 00000000..d6f61db9 --- /dev/null +++ b/src/screens/LoginScreen.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Eye, EyeOff } from 'lucide-react-native'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; +import { Button, Input } from '../components'; +import { useAuthStore } from '../stores/auth-store'; + +interface LoginScreenProps { + onLogin?: () => void; +} + +export default function LoginScreen({ onLogin }: LoginScreenProps) { + const login = useAuthStore((state) => state.login); + const isLoading = useAuthStore((state) => state.isLoading); + const error = useAuthStore((state) => state.error); + const clearError = useAuthStore((state) => state.clearError); + + const [serverUrl, setServerUrl] = React.useState(''); + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [showPassword, setShowPassword] = React.useState(false); + + const canSubmit = Boolean(serverUrl.trim() && email.trim() && password); + + const handleLogin = async () => { + if (!canSubmit) { + return; + } + + try { + await login(serverUrl.trim(), email.trim(), password); + onLogin?.(); + } catch { + // Store state already contains the user-facing error. + } + }; + + const updateField = React.useCallback( + (setter: React.Dispatch>) => (value: string) => { + if (useAuthStore.getState().error) { + clearError(); + } + setter(value); + }, + [clearError], + ); + + const onChangeServerUrl = React.useMemo(() => updateField(setServerUrl), [updateField]); + const onChangeEmail = React.useMemo(() => updateField(setEmail), [updateField]); + const onChangePassword = React.useMemo(() => updateField(setPassword), [updateField]); + + return ( + + + + {/* Logo & Branding */} + + + + + Bulwark Mail + Secure. Private. Yours. + + + {/* Login Form */} + + + + + + { + void handleLogin(); + }} + rightIcon={ + setShowPassword(!showPassword)}> + {showPassword ? ( + + ) : ( + + )} + + } + /> + + + + {error ? {error} : null} + + {/* OAuth divider */} + + + or continue with + + + + {/* OAuth buttons */} + + + + + + {/* Footer */} + + Bulwark Mobile v0.0.1 + Privacy Policy + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + keyboardView: { flex: 1 }, + content: { + flex: 1, + justifyContent: 'center', + paddingHorizontal: spacing.xxl, + }, + branding: { alignItems: 'center', marginBottom: 40 }, + logoContainer: { + width: 80, + height: 80, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.lg, + }, + logoImage: { + width: 72, + height: 72, + }, + appName: { ...typography.h1, color: colors.text }, + tagline: { ...typography.caption, color: colors.textMuted, marginTop: spacing.xs }, + + form: { gap: spacing.lg }, + + loginButton: { + marginTop: spacing.sm, + }, + errorText: { + ...typography.caption, + color: colors.error, + textAlign: 'center', + }, + + divider: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: spacing.md, + gap: spacing.md, + }, + dividerLine: { flex: 1, height: 1, backgroundColor: colors.border }, + dividerText: { ...typography.caption, color: colors.textMuted }, + + oauthRow: { gap: spacing.md }, + + footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs }, + footerText: { ...typography.caption, color: colors.textMuted }, + footerLink: { ...typography.caption, color: colors.textLink }, +}); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx new file mode 100644 index 00000000..584421e9 --- /dev/null +++ b/src/screens/SettingsScreen.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native'; +import { Settings, User, Palette, Bell, Shield, ChevronRight } from 'lucide-react-native'; +import { colors } from '../theme/colors'; + +interface SettingItemProps { + icon: React.ReactNode; + title: string; + subtitle?: string; + rightElement?: React.ReactNode; +} + +function SettingItem({ icon, title, subtitle, rightElement }: SettingItemProps) { + return ( + [styles.settingRow, pressed && styles.settingRowPressed]}> + {icon} + + {title} + {subtitle && {subtitle}} + + {rightElement || } + + ); +} + +export default function SettingsScreen() { + const [darkMode, setDarkMode] = React.useState(false); + const [notifications, setNotifications] = React.useState(true); + + return ( + + + + Settings + + + + + B + + + Bulwark User + user@example.com + + + + + Account + } + title="Account Settings" + subtitle="Manage your account details" + /> + } + title="Privacy & Security" + subtitle="Passwords, encryption, trusted senders" + /> + + + + Preferences + } + title="Dark Mode" + rightElement={} + /> + } + title="Notifications" + rightElement={} + /> + + + + Bulwark Mobile v0.0.1 + Built with Expo + React Native + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: 8, + }, + headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text }, + profileCard: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + margin: 16, + backgroundColor: colors.surface, + borderRadius: 12, + }, + profileAvatar: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + marginRight: 16, + }, + profileAvatarText: { fontSize: 24, fontWeight: '700', color: colors.textInverse }, + profileInfo: { flex: 1 }, + profileName: { fontSize: 18, fontWeight: '600', color: colors.text }, + profileEmail: { fontSize: 14, color: colors.textSecondary, marginTop: 2 }, + section: { marginTop: 8 }, + sectionTitle: { + fontSize: 13, + fontWeight: '600', + color: colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + settingRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + }, + settingRowPressed: { backgroundColor: colors.surfaceHover }, + settingIcon: { marginRight: 14 }, + settingContent: { flex: 1 }, + settingTitle: { fontSize: 16, color: colors.text }, + settingSubtitle: { fontSize: 13, color: colors.textSecondary, marginTop: 2 }, + footer: { + alignItems: 'center', + paddingVertical: 32, + gap: 4, + }, + footerText: { fontSize: 12, color: colors.textMuted }, +}); diff --git a/src/screens/SettingsScreenNew.tsx b/src/screens/SettingsScreenNew.tsx new file mode 100644 index 00000000..f04f5220 --- /dev/null +++ b/src/screens/SettingsScreenNew.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + User, Mail, CalendarDays, Shield, Bell, Palette, Globe, Lock, + ChevronRight, LogOut, Moon, Server, Key, Fingerprint, Info, HelpCircle +} from 'lucide-react-native'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; + +interface SettingItem { + id: string; + icon: any; + iconColor: string; + iconBg: string; + label: string; + detail?: string; + type: 'link' | 'toggle'; + value?: boolean; +} + +const PROFILE = { + name: 'Jane Doe', + email: 'jane@bulwark.mail', + plan: 'Pro', +}; + +const SECTIONS: { title: string; items: SettingItem[] }[] = [ + { + title: 'Account', + items: [ + { id: 'profile', icon: User, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Profile', detail: PROFILE.email, type: 'link' }, + { id: 'identities', icon: Mail, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Identities & Signatures', type: 'link' }, + { id: 'security', icon: Lock, iconColor: colors.error, iconBg: colors.errorBg, label: 'Security', detail: '2FA enabled', type: 'link' }, + ], + }, + { + title: 'Email', + items: [ + { id: 'filters', icon: Mail, iconColor: colors.calendar.teal, iconBg: colors.calendar.tealBg, label: 'Filters & Rules', type: 'link' }, + { id: 'smime', icon: Shield, iconColor: colors.calendar.indigo, iconBg: colors.calendar.indigoBg, label: 'S/MIME Encryption', type: 'link' }, + { id: 'vacation', icon: CalendarDays, iconColor: colors.calendar.orange, iconBg: colors.warningBg, label: 'Vacation Responder', detail: 'Off', type: 'link' }, + ], + }, + { + title: 'Appearance', + items: [ + { id: 'theme', icon: Palette, iconColor: colors.calendar.pink, iconBg: colors.calendar.pinkBg, label: 'Theme', detail: 'System', type: 'link' }, + { id: 'darkMode', icon: Moon, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Dark Mode', type: 'toggle', value: false }, + { id: 'language', icon: Globe, iconColor: colors.calendar.blue, iconBg: colors.primaryBg, label: 'Language', detail: 'English', type: 'link' }, + ], + }, + { + title: 'Notifications', + items: [ + { id: 'pushNotif', icon: Bell, iconColor: colors.calendar.green, iconBg: colors.successBg, label: 'Push Notifications', type: 'toggle', value: true }, + { id: 'emailNotif', icon: Mail, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Email Notifications', type: 'toggle', value: true }, + ], + }, + { + title: 'Advanced', + items: [ + { id: 'server', icon: Server, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Server Settings', type: 'link' }, + { id: 'privacy', icon: Fingerprint, iconColor: colors.textSecondary, iconBg: colors.surface, label: 'Privacy', type: 'link' }, + { id: 'about', icon: Info, iconColor: colors.textMuted, iconBg: colors.surface, label: 'About', detail: 'v1.0.0', type: 'link' }, + { id: 'help', icon: HelpCircle, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Help & Support', type: 'link' }, + ], + }, +]; + +function SettingRow({ item }: { item: SettingItem }) { + const Icon = item.icon; + const [toggled, setToggled] = React.useState(item.value ?? false); + + return ( + [styles.settingRow, pressed && item.type === 'link' && styles.settingRowPressed]} + > + + + + + {item.label} + {item.detail && item.type === 'link' && ( + {item.detail} + )} + + {item.type === 'link' && ( + + )} + {item.type === 'toggle' && ( + + )} + + ); +} + +interface SettingsScreenProps { + onLogout?: () => void; +} + +export default function SettingsScreen({ onLogout }: SettingsScreenProps) { + return ( + + + {/* Profile card */} + + + JD + + + {PROFILE.name} + {PROFILE.email} + + + {PROFILE.plan} + + + + {/* Settings sections */} + {SECTIONS.map(section => ( + + {section.title} + + {section.items.map((item, idx) => ( + + {idx > 0 && } + + + ))} + + + ))} + + {/* Sign out */} + + + Sign Out + + + {/* Version info */} + Bulwark Mobile v0.0.1 + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + scrollContent: { paddingBottom: 40 }, + + // Profile card + profileCard: { + flexDirection: 'row', + alignItems: 'center', + margin: spacing.lg, + padding: spacing.lg, + backgroundColor: colors.surface, + borderRadius: radius.xl, + gap: spacing.md, + }, + profileAvatar: { + width: componentSizes.avatarLg, height: componentSizes.avatarLg, + borderRadius: componentSizes.avatarLg / 2, + backgroundColor: colors.primary, + alignItems: 'center', justifyContent: 'center', + }, + profileAvatarText: { ...typography.h3, color: colors.textInverse }, + profileInfo: { flex: 1 }, + profileName: { ...typography.bodyMedium, color: colors.text }, + profileEmail: { ...typography.caption, color: colors.textMuted, marginTop: 2 }, + planBadge: { + backgroundColor: colors.primaryBg, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: radius.full, + }, + planBadgeText: { ...typography.small, color: colors.primary }, + + // Sections + section: { + marginHorizontal: spacing.lg, + marginBottom: spacing.xl, + }, + sectionTitle: { + ...typography.small, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: spacing.sm, + marginLeft: spacing.xs, + }, + sectionCard: { + backgroundColor: colors.surface, + borderRadius: radius.lg, + overflow: 'hidden', + }, + sectionSeparator: { + height: 1, + backgroundColor: colors.borderLight, + marginLeft: 56, + }, + + // Setting row + settingRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + gap: spacing.md, + }, + settingRowPressed: { backgroundColor: colors.surfaceHover }, + settingIcon: { + width: 32, height: 32, + borderRadius: radius.sm, + alignItems: 'center', justifyContent: 'center', + }, + settingContent: { flex: 1 }, + settingLabel: { ...typography.body, color: colors.text }, + settingDetail: { ...typography.small, color: colors.textMuted, marginTop: 1 }, + + // Sign out + signOutBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + marginHorizontal: spacing.lg, + marginTop: spacing.md, + paddingVertical: spacing.md, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.errorBorder, + backgroundColor: colors.errorBg, + }, + signOutText: { ...typography.bodyMedium, color: colors.error }, + + // Version + versionText: { + ...typography.small, + color: colors.textMuted, + textAlign: 'center', + marginTop: spacing.xl, + marginBottom: spacing.lg, + }, +}); diff --git a/src/screens/SettingsScreenNew2.tsx b/src/screens/SettingsScreenNew2.tsx new file mode 100644 index 00000000..11fd1321 --- /dev/null +++ b/src/screens/SettingsScreenNew2.tsx @@ -0,0 +1,265 @@ +import React from 'react'; +import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + ArrowLeft, ChevronRight, LogOut, Settings, + Palette, Mail, User, Shield, UserPen, Palmtree, Calendar, + Filter, FileText, FolderOpen, Tags, HardDrive, Wrench, + BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, + type LucideIcon, +} from 'lucide-react-native'; +import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; + +type Tab = + | 'appearance' | 'email' | 'notifications' + | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' + | 'filters' | 'templates' | 'folders' | 'keywords' + | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' + | 'themes' | 'plugins' | 'advanced'; + +type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; + +interface TabDef { + id: Tab; + label: string; + icon: LucideIcon; + group: TabGroup; + experimental?: boolean; +} + +const GROUP_LABELS: Record = { + general: 'General', + account: 'Account', + organization: 'Organization', + apps: 'Apps', + system: 'System', +}; + +const GROUP_ORDER: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system']; + +const TABS: TabDef[] = [ + { id: 'appearance', label: 'Appearance', icon: Palette, group: 'general' }, + { id: 'email', label: 'Email', icon: Mail, group: 'general' }, + { id: 'notifications',label: 'Notifications', icon: Bell, group: 'general' }, + { id: 'account', label: 'Account', icon: User, group: 'account' }, + { id: 'security', label: 'Security', icon: Shield, group: 'account' }, + { id: 'identities', label: 'Identities', icon: UserPen, group: 'account' }, + { id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account' }, + { id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account' }, + { id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization' }, + { id: 'templates', label: 'Templates', icon: FileText, group: 'organization' }, + { id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization' }, + { id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization' }, + { id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps' }, + { id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps' }, + { id: 'files', label: 'Files', icon: HardDrive, group: 'apps' }, + { id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps' }, + { id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true }, + { id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true }, + { id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system' }, +]; + +function groupTabs() { + return GROUP_ORDER.map(group => ({ + group, + label: GROUP_LABELS[group], + items: TABS.filter(t => t.group === group), + })).filter(g => g.items.length > 0); +} + +interface SettingsScreenProps { + onLogout?: () => void; + onBack?: () => void; + onTabSelect?: (tab: Tab) => void; +} + +export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) { + const groupedTabs = groupTabs(); + + return ( + + {/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */} + + [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} + > + + + + Settings + + + {/* Tab list — matches webmail mobile: flat grouped list, no cards */} + + + {groupedTabs.map((group, groupIndex) => ( + + {groupIndex > 0 && } + + + {group.label} + + + {group.items.map((tab) => { + const Icon = tab.icon; + return ( + onTabSelect?.(tab.id)} + style={({ pressed }) => [styles.tabItem, pressed && styles.tabItemPressed]} + > + + + {tab.label} + {tab.experimental && ( + + Experimental + + )} + + + + ); + })} + + ))} + + + {/* Logout — matches webmail: border-t, destructive text, icon + label */} + + [styles.logoutBtn, pressed && styles.logoutBtnPressed]} + > + + Sign Out + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + + // Header — webmail: h-14, px-4, border-b, flex items-center gap-2 + header: { + flexDirection: 'row', + alignItems: 'center', + height: componentSizes.headerHeight, + paddingHorizontal: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: spacing.sm, + }, + headerBackBtn: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.md, + }, + headerBackBtnPressed: { + backgroundColor: colors.accent, + }, + headerTitle: { + ...typography.h3, + color: colors.text, + }, + + scrollArea: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + + tabList: { + paddingVertical: spacing.sm, + }, + + // Group divider — webmail: mx-5 my-2 border-t border-border + groupDivider: { + height: 1, + backgroundColor: colors.border, + marginHorizontal: 20, + marginVertical: spacing.sm, + }, + + // Group header — webmail: px-5 pt-3 pb-1.5 + groupHeader: { + paddingHorizontal: 20, + paddingTop: spacing.md, + paddingBottom: 6, + }, + groupLabel: { + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.8, + color: colors.mutedForeground, + }, + + // Tab item — webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight + tabItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 14, + }, + tabItemPressed: { + backgroundColor: colors.muted, + }, + tabItemLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + flex: 1, + }, + tabItemLabel: { + ...typography.body, + color: colors.text, + }, + + // Experimental badge — webmail: text-[10px] rounded-full bg-warning/15 text-warning + experimentalBadge: { + backgroundColor: 'rgba(202, 138, 4, 0.15)', + borderRadius: radius.full, + paddingHorizontal: 6, + paddingVertical: 2, + }, + experimentalText: { + fontSize: 10, + fontWeight: '500', + color: colors.warning, + }, + + // Logout — webmail: border-t border-border, px-5 py-3, destructive text + logoutSection: { + borderTopWidth: 1, + borderTopColor: colors.border, + paddingHorizontal: 20, + paddingVertical: spacing.md, + }, + logoutBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: 10, + paddingHorizontal: spacing.sm, + borderRadius: radius.sm, + }, + logoutBtnPressed: { + backgroundColor: colors.muted, + }, + logoutText: { + ...typography.body, + color: colors.error, + }, +}); diff --git a/src/stores/__tests__/auth-store.test.ts b/src/stores/__tests__/auth-store.test.ts new file mode 100644 index 00000000..a4646ace --- /dev/null +++ b/src/stores/__tests__/auth-store.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('expo-secure-store', () => ({ + setItemAsync: vi.fn(), + getItemAsync: vi.fn(), + deleteItemAsync: vi.fn(), +})); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + connect: vi.fn(), + logout: vi.fn(), + restoreSession: vi.fn(), + accountId: 'acc-1', + currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, + }, + AuthenticationError: class AuthenticationError extends Error { + constructor(msg: string) { super(msg); this.name = 'AuthenticationError'; } + }, +})); + +import { jmapClient } from '../../api/jmap-client'; +import { useAuthStore } from '../auth-store'; + +const mockConnect = jmapClient.connect as ReturnType; +const mockLogout = jmapClient.logout as ReturnType; +const mockRestore = jmapClient.restoreSession as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + useAuthStore.setState({ + isAuthenticated: false, + isLoading: false, + error: null, + serverUrl: null, + username: null, + session: null, + accountId: null, + }); +}); + +describe('auth-store', () => { + describe('login', () => { + it('should set authenticated state on success', async () => { + const session = { apiUrl: 'https://mail.example.com/jmap/' }; + mockConnect.mockResolvedValue(session); + + await useAuthStore.getState().login('https://mail.example.com', 'user', 'pass'); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.isLoading).toBe(false); + expect(state.serverUrl).toBe('https://mail.example.com'); + expect(state.username).toBe('user'); + expect(state.session).toEqual(session); + expect(state.accountId).toBe('acc-1'); + }); + + it('should set error on failure', async () => { + mockConnect.mockRejectedValue(new Error('Connection refused')); + + await expect( + useAuthStore.getState().login('https://fail.com', 'user', 'pass'), + ).rejects.toThrow(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.isLoading).toBe(false); + expect(state.error).toBe('Connection refused'); + }); + + it('should set friendly message for AuthenticationError', async () => { + const { AuthenticationError } = await import('../../api/jmap-client'); + mockConnect.mockRejectedValue(new AuthenticationError('Invalid')); + + await expect( + useAuthStore.getState().login('https://mail.example.com', 'user', 'bad'), + ).rejects.toThrow(); + + expect(useAuthStore.getState().error).toBe('Invalid username or password'); + }); + }); + + describe('logout', () => { + it('should reset all state', async () => { + useAuthStore.setState({ isAuthenticated: true, serverUrl: 'x', username: 'y' }); + mockLogout.mockResolvedValue(undefined); + + await useAuthStore.getState().logout(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.serverUrl).toBeNull(); + expect(state.username).toBeNull(); + }); + }); + + describe('restoreSession', () => { + it('should restore on success', async () => { + mockRestore.mockResolvedValue(true); + + const restored = await useAuthStore.getState().restoreSession(); + + expect(restored).toBe(true); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); + + it('should return false when no session to restore', async () => { + mockRestore.mockResolvedValue(false); + + const restored = await useAuthStore.getState().restoreSession(); + + expect(restored).toBe(false); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + + describe('clearError', () => { + it('should clear the error', () => { + useAuthStore.setState({ error: 'Some error' }); + useAuthStore.getState().clearError(); + expect(useAuthStore.getState().error).toBeNull(); + }); + }); +}); diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts new file mode 100644 index 00000000..14b6518d --- /dev/null +++ b/src/stores/__tests__/calendar-store.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../api/calendar', () => ({ + getCalendars: vi.fn(), + queryEvents: vi.fn(), + getEvents: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvents: vi.fn(), +})); + +import * as calendarApi from '../../api/calendar'; +import { useCalendarStore } from '../calendar-store'; + +const mockGetCalendars = calendarApi.getCalendars as ReturnType; +const mockQueryEvents = calendarApi.queryEvents as ReturnType; +const mockGetEvents = calendarApi.getEvents as ReturnType; +const mockCreateEvent = calendarApi.createEvent as ReturnType; +const mockUpdateEvent = calendarApi.updateEvent as ReturnType; +const mockDeleteEvents = calendarApi.deleteEvents as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + useCalendarStore.getState().reset(); +}); + +describe('calendar-store', () => { + describe('fetchCalendars', () => { + it('should load calendars', async () => { + const calendars = [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }]; + mockGetCalendars.mockResolvedValue(calendars); + + await useCalendarStore.getState().fetchCalendars(); + + expect(useCalendarStore.getState().calendars).toEqual(calendars); + }); + + it('should set error on failure', async () => { + mockGetCalendars.mockRejectedValue(new Error('Timeout')); + + await useCalendarStore.getState().fetchCalendars(); + + expect(useCalendarStore.getState().error).toBe('Timeout'); + }); + }); + + describe('fetchEvents', () => { + it('should query and fetch events', async () => { + mockQueryEvents.mockResolvedValue(['ev1']); + const events = [{ id: 'ev1', title: 'Meeting' }]; + mockGetEvents.mockResolvedValue(events); + + await useCalendarStore.getState().fetchEvents( + ['cal-1'], + '2026-03-01T00:00:00Z', + '2026-03-31T23:59:59Z', + ); + + expect(useCalendarStore.getState().events).toEqual(events); + expect(useCalendarStore.getState().loading).toBe(false); + }); + + it('should handle empty results', async () => { + mockQueryEvents.mockResolvedValue([]); + + await useCalendarStore.getState().fetchEvents(['cal-1'], '2026-03-01', '2026-03-31'); + + expect(useCalendarStore.getState().events).toEqual([]); + expect(mockGetEvents).not.toHaveBeenCalled(); + }); + }); + + describe('createEvent', () => { + it('should create and append event', async () => { + useCalendarStore.setState({ events: [{ id: 'ev1' } as any] }); + const created = { id: 'ev-new', title: 'Lunch' }; + mockCreateEvent.mockResolvedValue(created); + + const result = await useCalendarStore.getState().createEvent( + { title: 'Lunch', start: '2026-03-30T12:00:00' }, + 'cal-1', + ); + + expect(result).toEqual(created); + expect(useCalendarStore.getState().events).toHaveLength(2); + }); + }); + + describe('updateEvent', () => { + it('should update and merge in state', async () => { + useCalendarStore.setState({ + events: [{ id: 'ev1', title: 'Meeting' } as any], + }); + mockUpdateEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().updateEvent('ev1', { title: 'Updated Meeting' } as any); + + expect(useCalendarStore.getState().events[0].title).toBe('Updated Meeting'); + }); + }); + + describe('deleteEvent', () => { + it('should remove event from list', async () => { + useCalendarStore.setState({ + events: [{ id: 'ev1' } as any, { id: 'ev2' } as any], + }); + mockDeleteEvents.mockResolvedValue(undefined); + + await useCalendarStore.getState().deleteEvent('ev1'); + + expect(useCalendarStore.getState().events).toHaveLength(1); + expect(useCalendarStore.getState().events[0].id).toBe('ev2'); + }); + }); +}); diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts new file mode 100644 index 00000000..80292e2c --- /dev/null +++ b/src/stores/__tests__/contacts-store.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../api/contacts', () => ({ + getAddressBooks: vi.fn(), + queryContacts: vi.fn(), + getContacts: vi.fn(), + createContact: vi.fn(), + updateContact: vi.fn(), + deleteContacts: vi.fn(), +})); + +import * as contactsApi from '../../api/contacts'; +import { useContactsStore } from '../contacts-store'; + +const mockGetAddressBooks = contactsApi.getAddressBooks as ReturnType; +const mockQueryContacts = contactsApi.queryContacts as ReturnType; +const mockGetContacts = contactsApi.getContacts as ReturnType; +const mockCreateContact = contactsApi.createContact as ReturnType; +const mockUpdateContact = contactsApi.updateContact as ReturnType; +const mockDeleteContacts = contactsApi.deleteContacts as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + useContactsStore.getState().reset(); +}); + +describe('contacts-store', () => { + describe('fetchAddressBooks', () => { + it('should load address books', async () => { + const books = [{ id: 'ab-1', name: 'Personal' }]; + mockGetAddressBooks.mockResolvedValue(books); + + await useContactsStore.getState().fetchAddressBooks(); + + expect(useContactsStore.getState().addressBooks).toEqual(books); + }); + }); + + describe('fetchContacts', () => { + it('should query and fetch contacts', async () => { + mockQueryContacts.mockResolvedValue(['c1', 'c2']); + const contacts = [{ id: 'c1' }, { id: 'c2' }]; + mockGetContacts.mockResolvedValue(contacts); + + await useContactsStore.getState().fetchContacts({ text: 'john' }); + + expect(useContactsStore.getState().contacts).toEqual(contacts); + expect(useContactsStore.getState().loading).toBe(false); + }); + + it('should handle empty results', async () => { + mockQueryContacts.mockResolvedValue([]); + + await useContactsStore.getState().fetchContacts(); + + expect(useContactsStore.getState().contacts).toEqual([]); + expect(mockGetContacts).not.toHaveBeenCalled(); + }); + }); + + describe('createContact', () => { + it('should create and append to list', async () => { + useContactsStore.setState({ contacts: [{ id: 'c1' } as any] }); + const created = { id: 'c-new', addressBookIds: { 'ab-1': true } }; + mockCreateContact.mockResolvedValue(created); + + const result = await useContactsStore.getState().createContact({}, 'ab-1'); + + expect(result).toEqual(created); + expect(useContactsStore.getState().contacts).toHaveLength(2); + }); + }); + + describe('updateContact', () => { + it('should update and merge changes in state', async () => { + useContactsStore.setState({ + contacts: [{ id: 'c1', kind: 'individual' } as any], + }); + mockUpdateContact.mockResolvedValue(undefined); + + await useContactsStore.getState().updateContact('c1', { kind: 'org' } as any); + + expect(useContactsStore.getState().contacts[0].kind).toBe('org'); + }); + }); + + describe('deleteContact', () => { + it('should remove from list', async () => { + useContactsStore.setState({ + contacts: [{ id: 'c1' } as any, { id: 'c2' } as any], + }); + mockDeleteContacts.mockResolvedValue(undefined); + + await useContactsStore.getState().deleteContact('c1'); + + expect(useContactsStore.getState().contacts).toHaveLength(1); + expect(useContactsStore.getState().contacts[0].id).toBe('c2'); + }); + }); +}); diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts new file mode 100644 index 00000000..e1848f60 --- /dev/null +++ b/src/stores/__tests__/email-store.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../api/email', () => ({ + getMailboxes: vi.fn(), + queryEmails: vi.fn(), + getEmails: vi.fn(), + getFullEmail: vi.fn(), + setEmailKeywords: vi.fn(), + moveEmail: vi.fn(), + deleteEmail: vi.fn(), + searchEmails: vi.fn(), +})); + +import * as emailApi from '../../api/email'; +import { useEmailStore } from '../email-store'; + +const mockGetMailboxes = emailApi.getMailboxes as ReturnType; +const mockQueryEmails = emailApi.queryEmails as ReturnType; +const mockGetEmails = emailApi.getEmails as ReturnType; +const mockGetFullEmail = emailApi.getFullEmail as ReturnType; +const mockSetKeywords = emailApi.setEmailKeywords as ReturnType; +const mockMoveEmail = emailApi.moveEmail as ReturnType; +const mockDeleteEmail = emailApi.deleteEmail as ReturnType; +const mockSearchEmails = emailApi.searchEmails as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + useEmailStore.getState().reset(); +}); + +describe('email-store', () => { + describe('fetchMailboxes', () => { + it('should load mailboxes', async () => { + const mailboxes = [ + { id: 'mb-1', name: 'Inbox', role: 'inbox' }, + { id: 'mb-2', name: 'Sent', role: 'sent' }, + ]; + mockGetMailboxes.mockResolvedValue(mailboxes); + + await useEmailStore.getState().fetchMailboxes(); + + expect(useEmailStore.getState().mailboxes).toEqual(mailboxes); + }); + + it('should set error on failure', async () => { + mockGetMailboxes.mockRejectedValue(new Error('Network error')); + + await useEmailStore.getState().fetchMailboxes(); + + expect(useEmailStore.getState().error).toBe('Network error'); + }); + }); + + describe('selectMailbox', () => { + it('should query and fetch emails for mailbox', async () => { + mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2 }); + const emails = [ + { id: 'e1', subject: 'Email 1' }, + { id: 'e2', subject: 'Email 2' }, + ]; + mockGetEmails.mockResolvedValue(emails); + + await useEmailStore.getState().selectMailbox('mb-1'); + + const state = useEmailStore.getState(); + expect(state.currentMailboxId).toBe('mb-1'); + expect(state.emails).toEqual(emails); + expect(state.totalEmails).toBe(2); + expect(state.loading).toBe(false); + }); + + it('should handle empty mailbox', async () => { + mockQueryEmails.mockResolvedValue({ ids: [], total: 0 }); + + await useEmailStore.getState().selectMailbox('mb-empty'); + + expect(useEmailStore.getState().emails).toEqual([]); + expect(mockGetEmails).not.toHaveBeenCalled(); + }); + }); + + describe('loadMoreEmails', () => { + it('should append batch to existing emails', async () => { + // Set up state with initial load + useEmailStore.setState({ + currentMailboxId: 'mb-1', + emails: [{ id: 'e1' } as any], + totalEmails: 2, + loading: false, + }); + + mockQueryEmails.mockResolvedValue({ ids: ['e2'], total: 2 }); + mockGetEmails.mockResolvedValue([{ id: 'e2', subject: 'Email 2' }]); + + await useEmailStore.getState().loadMoreEmails(); + + expect(useEmailStore.getState().emails).toHaveLength(2); + }); + + it('should not load if already at total', async () => { + useEmailStore.setState({ + currentMailboxId: 'mb-1', + emails: [{ id: 'e1' } as any, { id: 'e2' } as any], + totalEmails: 2, + loading: false, + }); + + await useEmailStore.getState().loadMoreEmails(); + + expect(mockQueryEmails).not.toHaveBeenCalled(); + }); + }); + + describe('markRead', () => { + it('should update keywords and optimistically update state', async () => { + useEmailStore.setState({ + emails: [{ id: 'e1', keywords: {} } as any], + }); + mockSetKeywords.mockResolvedValue(undefined); + + await useEmailStore.getState().markRead('e1'); + + expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true }); + expect(useEmailStore.getState().emails[0].keywords.$seen).toBe(true); + }); + }); + + describe('markUnread', () => { + it('should remove $seen keyword', async () => { + useEmailStore.setState({ + emails: [{ id: 'e1', keywords: { $seen: true, $flagged: true } } as any], + }); + mockSetKeywords.mockResolvedValue(undefined); + + await useEmailStore.getState().markUnread('e1'); + + expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true }); + expect(useEmailStore.getState().emails[0].keywords.$seen).toBeUndefined(); + }); + }); + + describe('toggleStar', () => { + it('should add $flagged keyword', async () => { + useEmailStore.setState({ + emails: [{ id: 'e1', keywords: { $seen: true } } as any], + }); + mockSetKeywords.mockResolvedValue(undefined); + + await useEmailStore.getState().toggleStar('e1', true); + + expect(useEmailStore.getState().emails[0].keywords.$flagged).toBe(true); + }); + + it('should remove $flagged keyword', async () => { + useEmailStore.setState({ + emails: [{ id: 'e1', keywords: { $seen: true, $flagged: true } } as any], + }); + mockSetKeywords.mockResolvedValue(undefined); + + await useEmailStore.getState().toggleStar('e1', false); + + expect(useEmailStore.getState().emails[0].keywords.$flagged).toBeUndefined(); + }); + }); + + describe('moveToMailbox', () => { + it('should move and remove from list', async () => { + useEmailStore.setState({ + emails: [{ id: 'e1' } as any, { id: 'e2' } as any], + }); + mockMoveEmail.mockResolvedValue(undefined); + + await useEmailStore.getState().moveToMailbox('e1', 'inbox', 'archive'); + + expect(mockMoveEmail).toHaveBeenCalledWith('e1', 'inbox', 'archive'); + expect(useEmailStore.getState().emails).toHaveLength(1); + expect(useEmailStore.getState().emails[0].id).toBe('e2'); + }); + }); + + describe('deleteEmail', () => { + it('should delete and remove from list', async () => { + useEmailStore.setState({ + emails: [{ id: 'e1' } as any], + }); + mockDeleteEmail.mockResolvedValue(undefined); + + await useEmailStore.getState().deleteEmail('e1', 'trash', 'inbox'); + + expect(useEmailStore.getState().emails).toHaveLength(0); + }); + }); + + describe('searchEmails', () => { + it('should search and return full email objects', async () => { + mockSearchEmails.mockResolvedValue(['e1']); + mockGetEmails.mockResolvedValue([{ id: 'e1', subject: 'Found' }]); + + const results = await useEmailStore.getState().searchEmails('test'); + + expect(results).toEqual([{ id: 'e1', subject: 'Found' }]); + }); + + it('should return empty array for no results', async () => { + mockSearchEmails.mockResolvedValue([]); + + const results = await useEmailStore.getState().searchEmails('nothing'); + + expect(results).toEqual([]); + }); + }); + + describe('getEmailDetail', () => { + it('should fetch full email', async () => { + const fullEmail = { id: 'e1', subject: 'Test', bodyValues: { html: { value: '

Hi

' } } }; + mockGetFullEmail.mockResolvedValue(fullEmail); + + const result = await useEmailStore.getState().getEmailDetail('e1'); + + expect(result).toEqual(fullEmail); + }); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts new file mode 100644 index 00000000..cf63b398 --- /dev/null +++ b/src/stores/auth-store.ts @@ -0,0 +1,97 @@ +import { create } from 'zustand'; +import { jmapClient, AuthenticationError } from '../api/jmap-client'; +import type { JMAPSession } from '../api/types'; + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + hasRestoredSession: boolean; + error: string | null; + serverUrl: string | null; + username: string | null; + session: JMAPSession | null; + accountId: string | null; + client: typeof jmapClient | null; + + login: (serverUrl: string, username: string, password: string) => Promise; + logout: () => Promise; + restoreSession: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create((set) => ({ + isAuthenticated: false, + isLoading: false, + hasRestoredSession: false, + error: null, + serverUrl: null, + username: null, + session: null, + accountId: null, + client: null, + + login: async (serverUrl, username, password) => { + set({ isLoading: true, error: null }); + try { + const session = await jmapClient.connect(serverUrl, username, password); + set({ + isAuthenticated: true, + isLoading: false, + hasRestoredSession: true, + serverUrl, + username, + session, + accountId: jmapClient.accountId, + client: jmapClient, + }); + } catch (err) { + const message = err instanceof AuthenticationError + ? 'Invalid username or password' + : err instanceof Error + ? err.message + : 'Connection failed'; + set({ isLoading: false, error: message }); + throw err; + } + }, + + logout: async () => { + await jmapClient.logout(); + set({ + isAuthenticated: false, + isLoading: false, + hasRestoredSession: true, + error: null, + serverUrl: null, + username: null, + session: null, + accountId: null, + client: null, + }); + }, + + restoreSession: async () => { + set({ isLoading: true }); + try { + const restored = await jmapClient.restoreSession(); + if (restored) { + set({ + isAuthenticated: true, + isLoading: false, + hasRestoredSession: true, + session: jmapClient.currentSession, + accountId: jmapClient.accountId, + client: jmapClient, + }); + } else { + set({ isLoading: false, hasRestoredSession: true }); + } + return restored; + } catch { + set({ isLoading: false, hasRestoredSession: true }); + return false; + } + }, + + clearError: () => set({ error: null }), +})); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts new file mode 100644 index 00000000..0cb20f14 --- /dev/null +++ b/src/stores/calendar-store.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import type { Calendar, CalendarEvent } from '../api/types'; +import { + getCalendars as fetchCalendars, + queryEvents, + getEvents as fetchEvents, + createEvent as apiCreateEvent, + updateEvent as apiUpdateEvent, + deleteEvents as apiDeleteEvents, +} from '../api/calendar'; + +export interface CalendarState { + calendars: Calendar[]; + events: CalendarEvent[]; + loading: boolean; + error: string | null; + + fetchCalendars: () => Promise; + fetchEvents: (calendarIds: string[], after: string, before: string) => Promise; + createEvent: (event: Partial, calendarId: string) => Promise; + updateEvent: (id: string, changes: Partial) => Promise; + deleteEvent: (id: string) => Promise; + reset: () => void; +} + +export const useCalendarStore = create((set, get) => ({ + calendars: [], + events: [], + loading: false, + error: null, + + fetchCalendars: async () => { + try { + const calendars = await fetchCalendars(); + set({ calendars }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); + } + }, + + fetchEvents: async (calendarIds, after, before) => { + set({ loading: true, error: null }); + try { + const ids = await queryEvents(calendarIds, after, before); + const events = ids.length > 0 ? await fetchEvents(ids) : []; + set({ events, loading: false }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); + } + }, + + createEvent: async (event, calendarId) => { + const created = await apiCreateEvent(event, calendarId); + set({ events: [...get().events, created] }); + return created; + }, + + updateEvent: async (id, changes) => { + await apiUpdateEvent(id, changes); + set({ + events: get().events.map((e) => + e.id === id ? { ...e, ...changes } : e, + ), + }); + }, + + deleteEvent: async (id) => { + await apiDeleteEvents([id]); + set({ events: get().events.filter((e) => e.id !== id) }); + }, + + reset: () => set({ + calendars: [], + events: [], + loading: false, + error: null, + }), +})); diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts new file mode 100644 index 00000000..68e1b175 --- /dev/null +++ b/src/stores/contacts-store.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import type { ContactCard, AddressBook } from '../api/types'; +import { + getAddressBooks as fetchAddressBooks, + queryContacts, + getContacts as fetchContacts, + createContact as apiCreateContact, + updateContact as apiUpdateContact, + deleteContacts as apiDeleteContacts, +} from '../api/contacts'; + +export interface ContactsState { + addressBooks: AddressBook[]; + contacts: ContactCard[]; + loading: boolean; + error: string | null; + + fetchAddressBooks: () => Promise; + fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise; + createContact: (contact: Partial, addressBookId: string) => Promise; + updateContact: (id: string, changes: Partial) => Promise; + deleteContact: (id: string) => Promise; + reset: () => void; +} + +export const useContactsStore = create((set, get) => ({ + addressBooks: [], + contacts: [], + loading: false, + error: null, + + fetchAddressBooks: async () => { + try { + const addressBooks = await fetchAddressBooks(); + set({ addressBooks }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to load address books' }); + } + }, + + fetchContacts: async (filter) => { + set({ loading: true, error: null }); + try { + const ids = await queryContacts(filter); + const contacts = ids.length > 0 ? await fetchContacts(ids) : []; + set({ contacts, loading: false }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' }); + } + }, + + createContact: async (contact, addressBookId) => { + const created = await apiCreateContact(contact, addressBookId); + set({ contacts: [...get().contacts, created] }); + return created; + }, + + updateContact: async (id, changes) => { + await apiUpdateContact(id, changes); + set({ + contacts: get().contacts.map((c) => + c.id === id ? { ...c, ...changes } : c, + ), + }); + }, + + deleteContact: async (id) => { + await apiDeleteContacts([id]); + set({ contacts: get().contacts.filter((c) => c.id !== id) }); + }, + + reset: () => set({ + addressBooks: [], + contacts: [], + loading: false, + error: null, + }), +})); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts new file mode 100644 index 00000000..398edaba --- /dev/null +++ b/src/stores/email-store.ts @@ -0,0 +1,154 @@ +import { create } from 'zustand'; +import type { Email, Mailbox } from '../api/types'; +import { + getMailboxes as fetchMailboxes, + queryEmails, + getEmails as fetchEmails, + getFullEmail, + setEmailKeywords, + moveEmail, + deleteEmail as apiDeleteEmail, + searchEmails as apiSearchEmails, +} from '../api/email'; + +export interface EmailState { + mailboxes: Mailbox[]; + currentMailboxId: string | null; + emails: Email[]; + totalEmails: number; + loading: boolean; + error: string | null; + + fetchMailboxes: () => Promise; + selectMailbox: (mailboxId: string) => Promise; + loadMoreEmails: () => Promise; + refreshEmails: () => Promise; + getEmailDetail: (id: string) => Promise; + markRead: (emailId: string) => Promise; + markUnread: (emailId: string) => Promise; + toggleStar: (emailId: string, starred: boolean) => Promise; + moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise; + deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise; + searchEmails: (query: string) => Promise; + reset: () => void; +} + +export const useEmailStore = create((set, get) => ({ + mailboxes: [], + currentMailboxId: null, + emails: [], + totalEmails: 0, + loading: false, + error: null, + + fetchMailboxes: async () => { + try { + const mailboxes = await fetchMailboxes(); + set({ mailboxes }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' }); + } + }, + + selectMailbox: async (mailboxId) => { + set({ currentMailboxId: mailboxId, loading: true, error: null, emails: [], totalEmails: 0 }); + try { + const { ids, total } = await queryEmails(mailboxId, { limit: 50 }); + const emails = ids.length > 0 ? await fetchEmails(ids) : []; + set({ emails, totalEmails: total, loading: false }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' }); + } + }, + + loadMoreEmails: async () => { + const { currentMailboxId, emails, totalEmails, loading } = get(); + if (!currentMailboxId || loading || emails.length >= totalEmails) return; + + set({ loading: true }); + try { + const { ids } = await queryEmails(currentMailboxId, { + position: emails.length, + limit: 50, + }); + const newEmails = ids.length > 0 ? await fetchEmails(ids) : []; + set({ emails: [...emails, ...newEmails], loading: false }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' }); + } + }, + + refreshEmails: async () => { + const { currentMailboxId } = get(); + if (currentMailboxId) { + await get().selectMailbox(currentMailboxId); + } + }, + + getEmailDetail: async (id) => { + return getFullEmail(id); + }, + + markRead: async (emailId) => { + await setEmailKeywords(emailId, { $seen: true }); + set({ + emails: get().emails.map((e) => + e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: true } } : e, + ), + }); + }, + + markUnread: async (emailId) => { + const email = get().emails.find((e) => e.id === emailId); + if (!email) return; + const { $seen, ...rest } = email.keywords; + await setEmailKeywords(emailId, rest); + set({ + emails: get().emails.map((e) => + e.id === emailId ? { ...e, keywords: rest } : e, + ), + }); + }, + + toggleStar: async (emailId, starred) => { + const email = get().emails.find((e) => e.id === emailId); + if (!email) return; + const keywords = { ...email.keywords }; + if (starred) { + keywords.$flagged = true; + } else { + delete keywords.$flagged; + } + await setEmailKeywords(emailId, keywords); + set({ + emails: get().emails.map((e) => + e.id === emailId ? { ...e, keywords } : e, + ), + }); + }, + + moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => { + await moveEmail(emailId, fromMailboxId, toMailboxId); + set({ emails: get().emails.filter((e) => e.id !== emailId) }); + }, + + deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => { + await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId); + set({ emails: get().emails.filter((e) => e.id !== emailId) }); + }, + + searchEmails: async (query) => { + const ids = await apiSearchEmails(query); + if (ids.length === 0) return []; + return fetchEmails(ids); + }, + + reset: () => set({ + mailboxes: [], + currentMailboxId: null, + emails: [], + totalEmails: 0, + loading: false, + error: null, + }), +})); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts new file mode 100644 index 00000000..39428e5a --- /dev/null +++ b/src/stores/settings-store.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import type { Identity } from '../api/types'; +import { getIdentities as fetchIdentities } from '../api/identity'; + +export interface SettingsState { + identities: Identity[]; + loading: boolean; + error: string | null; + + fetchIdentities: () => Promise; + reset: () => void; +} + +export const useSettingsStore = create((set) => ({ + identities: [], + loading: false, + error: null, + + fetchIdentities: async () => { + set({ loading: true, error: null }); + try { + const identities = await fetchIdentities(); + set({ identities, loading: false }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load identities' }); + } + }, + + reset: () => set({ + identities: [], + loading: false, + error: null, + }), +})); diff --git a/src/theme/colors.ts b/src/theme/colors.ts new file mode 100644 index 00000000..28d68380 --- /dev/null +++ b/src/theme/colors.ts @@ -0,0 +1,45 @@ +export const colors = { + // Primary brand colors (matching Bulwark web) + primary: '#3b82f6', + primaryDark: '#2563eb', + primaryLight: '#60a5fa', + + // Backgrounds + background: '#ffffff', + surface: '#f8fafc', + surfaceHover: '#f1f5f9', + + // Text + text: '#0f172a', + textSecondary: '#64748b', + textMuted: '#94a3b8', + textInverse: '#ffffff', + + // Borders + border: '#e2e8f0', + borderLight: '#f1f5f9', + + // Status + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + info: '#3b82f6', + + // Email-specific + unread: '#0f172a', + read: '#64748b', + starred: '#f59e0b', + flagged: '#ef4444', + + // Dark mode + dark: { + background: '#0f172a', + surface: '#1e293b', + surfaceHover: '#334155', + text: '#f8fafc', + textSecondary: '#94a3b8', + textMuted: '#64748b', + border: '#334155', + borderLight: '#1e293b', + }, +} as const; diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts new file mode 100644 index 00000000..359d421f --- /dev/null +++ b/src/theme/tokens.ts @@ -0,0 +1,194 @@ +// Qui Dark Theme — exact values from Bulwark Webmail's builtin-themes.ts +export const colors = { + // Primary brand (Qui theme) + primary: '#3b82f6', + primaryDark: '#2563eb', + primaryLight: '#60a5fa', + primaryBg: '#172554', + primaryBgHover: '#1e40af', + primaryForeground: '#ffffff', + + // Backgrounds (Qui dark) + background: '#09090b', + surface: '#18181b', // --color-secondary / --color-muted + surfaceHover: '#1f1f23', + surfaceActive: '#27272a', + card: '#141414', // --color-card + cardForeground: '#fafafa', + + // Text (Qui dark) + text: '#fafafa', // --color-foreground + textSecondary: '#a1a1aa', // --color-muted-foreground + textMuted: '#71717a', + textInverse: '#ffffff', + textLink: '#60a5fa', + + // Borders (Qui dark) + border: '#27272a', // --color-border / --color-input + borderLight: '#18181b', + borderFocus: '#3b82f6', // --color-ring + + // Status + success: '#16a34a', + successBg: '#052e16', + successForeground: '#ffffff', + warning: '#ca8a04', + warningBg: '#422006', + warningForeground: '#ffffff', + error: '#ef4444', // --color-destructive + errorBg: '#450a0a', + errorForeground: '#fafafa', + info: '#60a5fa', + infoForeground: '#ffffff', + + // Email-specific + unread: '#60a5fa', // --color-unread + read: '#a1a1aa', + starred: '#fbbf24', // amber-400 + flagged: '#ef4444', + draft: '#a78bfa', + // Border variants (for inline opacity hacks) + primaryBorder: 'rgba(59, 130, 246, 0.19)', // primary + '30' + errorBorder: 'rgba(239, 68, 68, 0.25)', // error + '40' + + // Calendar event colors + calendar: { + blue: '#60a5fa', + purple: '#a78bfa', + green: '#4ade80', + orange: '#fbbf24', + red: '#f87171', + pink: '#f472b6', + teal: '#2dd4bf', + indigo: '#818cf8', + // Dark-mode backgrounds for colored icons (replaces hardcoded light colors) + purpleBg: 'rgba(167, 139, 250, 0.08)', // purple-950/30 dark + tealBg: 'rgba(45, 212, 191, 0.08)', // teal-950/30 dark + indigoBg: 'rgba(129, 140, 248, 0.08)', // indigo-950/30 dark + pinkBg: 'rgba(244, 114, 182, 0.08)', // pink-950/30 dark + orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark + }, + + // Tag colors — from KEYWORD_PALETTE in stores/settings-store.ts + // dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400 + tags: { + red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' }, + orange: { dot: '#f97316', bg: 'rgba(67,20,7,0.30)', text: '#fb923c' }, + yellow: { dot: '#eab308', bg: 'rgba(66,32,6,0.30)', text: '#fbbf24' }, + green: { dot: '#22c55e', bg: 'rgba(5,46,22,0.30)', text: '#4ade80' }, + blue: { dot: '#3b82f6', bg: 'rgba(23,37,84,0.30)', text: '#60a5fa' }, + purple: { dot: '#a855f7', bg: 'rgba(59,7,100,0.30)', text: '#c084fc' }, + pink: { dot: '#ec4899', bg: 'rgba(80,7,36,0.30)', text: '#f472b6' }, + teal: { dot: '#14b8a6', bg: 'rgba(4,47,46,0.30)', text: '#2dd4bf' }, + cyan: { dot: '#06b6d4', bg: 'rgba(8,51,68,0.30)', text: '#22d3ee' }, + indigo: { dot: '#6366f1', bg: 'rgba(30,27,75,0.30)', text: '#818cf8' }, + amber: { dot: '#f59e0b', bg: 'rgba(69,26,3,0.30)', text: '#fbbf24' }, + lime: { dot: '#84cc16', bg: 'rgba(26,46,5,0.30)', text: '#a3e635' }, + gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' }, + }, + + // Navigation — matches web nav-rail + navActive: '#3b82f6', + navInactive: '#71717a', // --color-muted-foreground + navBadge: '#ef4444', // red-500 + + // Secondary / Muted (Qui dark) + secondary: '#18181b', + secondaryForeground: '#fafafa', + muted: '#18181b', + mutedForeground: '#a1a1aa', + + // Accent (Qui dark) + accent: '#172554', + accentForeground: '#93c5fd', + + // Selection (Qui dark) + selection: 'rgba(59, 130, 246, 0.25)', + selectionForeground: '#93c5fd', + + // Popover (Qui dark) + popover: '#18181b', + popoverForeground: '#fafafa', + + // Chart colors (Qui dark) + chart1: '#60a5fa', + chart2: '#4ade80', + chart3: '#fbbf24', + chart4: '#f87171', + chart5: '#a78bfa', +} as const; + +// Spacing matches Tailwind's rem scale converted to px +export const spacing = { + xs: 4, // 0.25rem + sm: 8, // 0.5rem + md: 12, // 0.75rem + lg: 16, // 1rem + xl: 20, // 1.25rem + xxl: 24, // 1.5rem + xxxl: 32, // 2rem +} as const; + +// Border radii matching Tailwind's rounded-* scale +export const radius = { + xs: 4, // rounded + sm: 6, // rounded-md + md: 8, // rounded-lg + lg: 12, // rounded-xl + xl: 16, // rounded-2xl + full: 9999, // rounded-full +} as const; + +// Typography matching web's Tailwind text-* classes +export const typography = { + // Headings + h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl + h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl + h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg + // Body = text-sm (14px) — the web uses text-sm for almost all content + body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 }, + bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 }, + bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 }, + bodyBold: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 }, + // Base = text-base (16px) — used for email content body + base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 }, + baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 }, + // Caption = text-xs (12px) + caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 }, + captionMedium: { fontSize: 12, fontWeight: '500' as const, lineHeight: 16 }, + // Small = 10px — for tags, badges, tab labels + small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 }, + tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 }, +} as const; + +// Component-specific sizes matching web +export const componentSizes = { + // Avatars (web: w-8/w-10/w-12) + avatarSm: 32, + avatarMd: 40, + avatarLg: 48, + // Nav icons (web: w-5 h-5 = 20px) + navIcon: 20, + // Status icons (web: w-3.5 h-3.5 = 14px) + statusIcon: 14, + // Mobile header height (web: h-14 = 56px) + headerHeight: 56, + // Input height (web: h-10 = 40px) + inputHeight: 40, + // Button heights + buttonSm: 36, // h-9 + buttonMd: 40, // h-10 + buttonLg: 44, // h-11 + // Tag dot (web: w-1.5 h-1.5 = 6px) + tagDot: 6, + // Event dot (web: w-1.5 h-1.5 = 6px) + eventDot: 6, + // Toggle switch (web: h-6 w-11) + toggleHeight: 24, + toggleWidth: 44, + toggleThumb: 16, + // FAB (standard material) + fab: 56, + // Nav badge (web: min-w-4 h-4 = 16px) + badgeSize: 16, +} as const; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..b9567f60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..d74d4049 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/__tests__/**/*.test.ts'], + }, +});