Initial commit: React Native JMAP webmail app

This commit is contained in:
Linus Rath
2026-03-30 00:49:22 +02:00
commit 11b9b86acd
71 changed files with 20247 additions and 0 deletions
+41
View File
@@ -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
+225
View File
@@ -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<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
function LoadingScreen({ message }: { message: string }) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>{message}</Text>
</View>
);
}
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
const mailboxes = useEmailStore((state) => state.mailboxes);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: {
backgroundColor: colors.background,
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarLabelStyle: {
fontSize: 10,
fontWeight: '600',
},
}}
>
<Tab.Screen
name="Mail"
options={{
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
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,
},
}}
>
{() => (
<EmailListScreen
onComposePress={() => navigation.navigate('Compose')}
onEmailPress={(email) => {
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
});
}}
/>
)}
</Tab.Screen>
<Tab.Screen
name="Calendar"
component={CalendarScreen}
options={{
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
}}
/>
<Tab.Screen
name="Contacts"
component={ContactsScreen}
options={{
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
}}
/>
<Tab.Screen
name="Files"
component={FilesScreen}
options={{
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
}}
/>
<Tab.Screen
name="Settings"
options={{
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
>
{() => <SettingsScreen onLogout={logout} />}
</Tab.Screen>
</Tab.Navigator>
);
}
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 (
<>
<StatusBar style="light" />
<LoadingScreen message="Restoring session..." />
</>
);
}
if (!isAuthenticated) {
return (
<>
<StatusBar style="light" />
<LoginScreen />
</>
);
}
return (
<NavigationContainer>
<StatusBar style="light" />
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
gap: spacing.md,
},
loadingText: {
...typography.body,
color: colors.textSecondary,
},
});
File diff suppressed because it is too large Load Diff
+521
View File
@@ -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<string> {
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 `<WebView>` |
| `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 (
<Tab.Navigator>
<Tab.Screen name="Email" component={EmailStack} />
<Tab.Screen name="Calendar" component={CalendarStack} />
<Tab.Screen name="Contacts" component={ContactsStack} />
<Tab.Screen name="Files" component={FilesStack} />
<Tab.Screen name="Settings" component={SettingsStack} />
</Tab.Navigator>
);
}
function RootNavigator() {
return (
<Stack.Navigator>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Main" component={MainTabs} />
</Stack.Navigator>
);
}
```
### 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` | `<Pressable>` + styles | NativeWind for Tailwind classes |
| `input.tsx` | `<TextInput>` | |
| `context-menu.tsx` | Action sheet / bottom sheet | `@gorhom/bottom-sheet` |
| `toast.tsx` | Toast notification | `react-native-toast-message` |
| `avatar.tsx` | `<Image>` + fallback `<View>` | |
| Modal system | `<Modal>` from RN | |
| Dropdown/Select | Bottom sheet picker | |
| Checkbox/Switch | `<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 `<WebView>` 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)
+379
View File
@@ -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 |
+310
View File
@@ -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)
<button className="hover:bg-accent active:bg-accent/80">
// Mobile (React Native)
<Pressable
style={({ pressed }) => [
styles.button,
pressed && styles.buttonPressed, // pressed = hover + active combined
]}
>
```
Focus rings on inputs are translated to `borderColor` + `shadow` changes on the `onFocus` / `onBlur` events of `TextInput`.
---
## Screen-Level Patterns
### Header Bar
Every screen uses the same header pattern matching the webmail's mobile header:
```tsx
// Webmail: <div className="h-14 px-4 border-b border-border flex items-center gap-2">
<View style={{
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight, // 56px = h-14
paddingHorizontal: spacing.lg, // 16px = px-4
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: spacing.sm, // 8px = gap-2
}}>
```
### Settings Screen (Flat Navigation List)
The settings screen (`SettingsScreenNew2.tsx`) matches the webmail's mobile settings layout exactly:
| Webmail Pattern | Mobile Implementation |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| Groups separated by `border-t border-border` | 1px `View` with `backgroundColor: colors.border` |
| Group header: `text-xs font-semibold uppercase tracking-wider text-muted-foreground` | `fontSize: 11, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.8, color: colors.mutedForeground` |
| Tab item: `px-5 py-3.5 text-sm` with icon + label + `ChevronRight` | `paddingHorizontal: 20, paddingVertical: 14` with 16px monochrome icon, label, and `ChevronRight` |
| `hover:bg-muted` on items | `pressed && { backgroundColor: colors.muted }` |
| Logout: `text-destructive` with `LogOut` icon, `border-t` | `color: colors.error`, `borderTopWidth: 1` separator |
Tab categories match the webmail 1:1: **General** (Appearance, Email, Notifications), **Account**, **Organization**, **Apps**, **System**.
### Email List
Uses a `FlatList` with row items that match the webmail's email list rows:
- Avatar with colored background from a fixed palette
- Sender name bolded when unread (`fontWeight: '600'`)
- Tag pills using `colors.tags.*` (bg + dot + text triplets)
- Starred indicator: `Star` icon filled with `colors.starred`
- FAB compose button: `Button` component with `variant="default" size="icon"`
### Compose Screen
Mirrors the webmail composer:
- Field rows with bottom borders matching `border-b border-border`
- Recipient chips: `backgroundColor: colors.primaryBg`, `borderRadius: radius.full`
- Send button: `Button variant="default" size="sm"` with `Send` icon
- Quote block: left border accent using `colors.border`
---
## Adding a New Component
To port a new webmail component to the mobile app:
1. **Read the webmail component** — identify Tailwind classes and CSS variables used.
2. **Map tokens** — find the corresponding value in `tokens.ts`. If a color or spacing doesn't exist, add it to the token file first.
3. **Create the RN component** in `src/components/` — match the prop interface (variant names, size names, etc.).
4. **Translate each Tailwind class** using the patterns documented above.
5. **Replace hover/focus states** with `Pressable` `pressed` and `TextInput` `onFocus`/`onBlur`.
6. **Export from `index.ts`** — add the barrel export.
7. **Match accessibility** — use `accessibilityRole`, `accessibilityState`, and `accessibilityLabel` where the webmail uses ARIA attributes.
### Checklist
- [ ] Token values match the webmail CSS variables
- [ ] Variant and size names are identical to the webmail component
- [ ] Pressed/focus states exist for all interactive elements
- [ ] Border radius uses token values, not hardcoded numbers
- [ ] Typography uses spread syntax (`...typography.body`) not manual fontSize/fontWeight
- [ ] Colors reference `colors.*` tokens, never hex literals (except for opacity variants like `rgba()`)
---
## File Structure
```
repos/react-native/
├── src/
│ ├── theme/
│ │ └── tokens.ts # All design tokens (colors, spacing, radius, typography, sizes)
│ ├── components/
│ │ ├── index.ts # Barrel exports
│ │ ├── Button.tsx # ← components/ui/button.tsx
│ │ ├── Input.tsx # ← components/ui/input.tsx
│ │ ├── Badge.tsx # ← components/ui/badge.tsx
│ │ ├── Card.tsx # ← components/ui/card.tsx + settings-section.tsx
│ │ ├── ToggleSwitch.tsx # ← settings-section.tsx ToggleSwitch
│ │ ├── RadioGroup.tsx # ← settings-section.tsx RadioGroup
│ │ └── Dialog.tsx # ← components/ui/confirm-dialog.tsx
│ └── screens/
│ ├── SettingsScreenNew2.tsx # ← app/[locale]/settings/page.tsx (mobile layout)
│ ├── LoginScreen.tsx # Uses Input, Button
│ ├── EmailListScreen.tsx # Uses Button (FAB)
│ ├── ComposeScreen.tsx # Uses Button (Send)
│ ├── CalendarScreenNew.tsx # Uses Button (add/today)
│ ├── ContactsScreenNew.tsx # Uses Button (add)
│ └── EmailThreadScreen.tsx # Uses Button (Reply/Forward)
└── App.tsx # Tab navigator, theme colors on tab bar
```
+31
View File
@@ -0,0 +1,31 @@
{
"expo": {
"name": "bulwark-mobile",
"slug": "bulwark-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#09090b"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#09090b"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-secure-store"]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_NaDFcABDqKTMKzlf06KDE6XhrEyDoRke"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_NaDFcABDqKTMKzlf06KDE6XhrEyDoRke)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 373.467 557.247 L 55.226 271.52 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 373.467 557.247 Z " fill="rgb(219,45,84)"/><path d=" M 626.533 557.247 L 944.774 271.52 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 626.533 557.247 Z " fill="rgb(219,45,84)"/><path d=" M 500 638.743 L 500 638.743 C 466.206 638.738 448.855 625.316 424.448 603.033 L 197.809 829.672 C 261.382 884.635 364.213 931.859 500 990 Q 500 990 500 990 C 635.787 931.859 738.618 884.635 802.191 829.672 L 575.552 603.033 C 551.145 625.316 533.794 638.738 500 638.743 Z " fill="rgb(219,45,84)"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_uJWgeJBwOedZODTMxPH94jQZcZX7d9eI"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_uJWgeJBwOedZODTMxPH94jQZcZX7d9eI)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 373.467 557.247 L 55.226 271.52 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 373.467 557.247 Z " fill="rgb(41,42,57)"/><path d=" M 626.533 557.247 L 944.774 271.52 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 626.533 557.247 Z " fill="rgb(41,42,57)"/><path d=" M 500 638.743 L 500 638.743 C 466.206 638.738 448.855 625.316 424.448 603.033 L 197.809 829.672 C 261.382 884.635 364.213 931.859 500 990 Q 500 990 500 990 C 635.787 931.859 738.618 884.635 802.191 829.672 L 575.552 603.033 C 551.145 625.316 533.794 638.738 500 638.743 Z " fill="rgb(41,42,57)"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(41,42,57)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_wUKLAwAEHmCzG90lDQ4mBZF94dpVJmOy"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_wUKLAwAEHmCzG90lDQ4mBZF94dpVJmOy)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 373.467 557.247 L 55.226 271.52 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 373.467 557.247 Z " fill="rgb(255,255,255)"/><path d=" M 626.533 557.247 L 944.774 271.52 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 626.533 557.247 Z " fill="rgb(255,255,255)"/><path d=" M 500 638.743 L 500 638.743 C 466.206 638.738 448.855 625.316 424.448 603.033 L 197.809 829.672 C 261.382 884.635 364.213 931.859 500 990 Q 500 990 500 990 C 635.787 931.859 738.618 884.635 802.191 829.672 L 575.552 603.033 C 551.145 625.316 533.794 638.738 500 638.743 Z " fill="rgb(255,255,255)"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(255,255,255)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+10200
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "bulwark-mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-sse": "^1.2.1",
"react-native-svg": "15.12.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2",
"vitest": "^4.1.2"
},
"private": true
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
currentSession: {
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
},
authHeader: 'Basic dXNlcjpwYXNz',
},
}));
import { getDownloadUrl } from '../blob';
beforeEach(() => {
vi.clearAllMocks();
});
describe('blob operations', () => {
describe('getDownloadUrl', () => {
it('should construct download URL from template', () => {
const url = getDownloadUrl('blob-123', 'document.pdf', 'application/pdf');
expect(url).toBe(
'https://mail.example.com/download/acc-1/blob-123/document.pdf?type=application%2Fpdf',
);
});
it('should use default name and type when not provided', () => {
const url = getDownloadUrl('blob-456');
expect(url).toContain('blob-456');
expect(url).toContain('download');
expect(url).toContain('application%2Foctet-stream');
});
it('should encode special characters in name', () => {
const url = getDownloadUrl('blob-789', 'my file (1).pdf', 'application/pdf');
expect(url).toContain('my%20file%20(1).pdf');
});
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import {
getCalendars,
queryEvents,
getEvents,
createEvent,
updateEvent,
deleteEvents,
} from '../calendar';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('calendar operations', () => {
describe('getCalendars', () => {
it('should fetch calendars', async () => {
const calendars = [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }];
mockRequest.mockResolvedValue({
methodResponses: [['Calendar/get', { list: calendars }, '0']],
});
const result = await getCalendars();
expect(result).toEqual(calendars);
expect(mockRequest).toHaveBeenCalledWith(
[['Calendar/get', { accountId: 'acc-1' }, '0']],
expect.arrayContaining(['urn:ietf:params:jmap:calendars']),
);
});
});
describe('queryEvents', () => {
it('should query events within date range', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
expect(result).toEqual(['ev1', 'ev2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
inCalendars: ['cal-1'],
after: '2026-03-01T00:00:00Z',
before: '2026-03-31T23:59:59Z',
types: ['Event'],
});
});
});
describe('getEvents', () => {
it('should fetch events by id with expected properties', async () => {
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const result = await getEvents(['ev1']);
expect(result).toEqual(events);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].properties).toContain('title');
expect(call[1].properties).toContain('start');
expect(call[1].properties).toContain('recurrenceRules');
});
});
describe('createEvent', () => {
it('should create an event in the specified calendar', async () => {
const created = { id: 'ev-new', title: 'Lunch', calendarIds: { 'cal-1': true } };
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { created: { 'new-event': created } }, '0']],
});
const result = await createEvent(
{ title: 'Lunch', start: '2026-03-30T12:00:00', duration: 'PT1H' },
'cal-1',
);
expect(result).toEqual(created);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create['new-event'].calendarIds).toEqual({ 'cal-1': true });
});
});
describe('updateEvent', () => {
it('should update event fields', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { updated: {} }, '0']],
});
await updateEvent('ev1', { title: 'Updated Meeting' });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ ev1: { title: 'Updated Meeting' } });
});
});
describe('deleteEvents', () => {
it('should destroy events by ids', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { destroyed: ['ev1'] }, '0']],
});
await deleteEvents(['ev1']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['ev1']);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import {
getAddressBooks,
queryContacts,
getContacts,
createContact,
updateContact,
deleteContacts,
} from '../contacts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('contacts operations', () => {
describe('getAddressBooks', () => {
it('should fetch address books', async () => {
const books = [{ id: 'ab-1', name: 'Personal' }];
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/get', { list: books }, '0']],
});
const result = await getAddressBooks();
expect(result).toEqual(books);
expect(mockRequest).toHaveBeenCalledWith(
[['AddressBook/get', { accountId: 'acc-1' }, '0']],
expect.arrayContaining(['urn:ietf:params:jmap:contacts']),
);
});
});
describe('queryContacts', () => {
it('should query contacts with text filter', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'] }, '0']],
});
const result = await queryContacts({ text: 'john' });
expect(result).toEqual(['c1', 'c2']);
});
it('should use default empty filter', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
});
await queryContacts();
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({});
});
});
describe('getContacts', () => {
it('should fetch contacts by id', async () => {
const contacts = [{ id: 'c1', name: { components: [{ kind: 'given', value: 'John' }] } }];
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/get', { list: contacts }, '0']],
});
const result = await getContacts(['c1']);
expect(result).toEqual(contacts);
});
});
describe('createContact', () => {
it('should create a contact in the specified address book', async () => {
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } };
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { created: { 'new-contact': created } }, '0']],
});
const result = await createContact(
{ name: { components: [{ kind: 'given', value: 'Jane' }] } },
'ab-1',
);
expect(result).toEqual(created);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create['new-contact'].addressBookIds).toEqual({ 'ab-1': true });
});
});
describe('updateContact', () => {
it('should update contact fields', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { updated: {} }, '0']],
});
await updateContact('c1', { kind: 'individual' });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ c1: { kind: 'individual' } });
});
});
describe('deleteContacts', () => {
it('should destroy contacts by ids', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { destroyed: ['c1', 'c2'] }, '0']],
});
await deleteContacts(['c1', 'c2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['c1', 'c2']);
});
});
});
+293
View File
@@ -0,0 +1,293 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the jmap-client module
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import {
getMailboxes,
queryEmails,
getEmails,
getFullEmail,
getThread,
setEmailKeywords,
moveEmail,
deleteEmail,
searchEmails,
sendEmail,
} from '../email';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('email operations', () => {
describe('getMailboxes', () => {
it('should fetch all mailboxes', async () => {
const mailboxes = [
{ id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 10, unreadEmails: 3 },
{ id: 'mb-2', name: 'Sent', role: 'sent', totalEmails: 5, unreadEmails: 0 },
];
mockRequest.mockResolvedValue({
methodResponses: [['Mailbox/get', { list: mailboxes }, '0']],
});
const result = await getMailboxes();
expect(result).toEqual(mailboxes);
expect(mockRequest).toHaveBeenCalledWith(
[['Mailbox/get', { accountId: 'acc-1' }, '0']],
);
});
});
describe('queryEmails', () => {
it('should query emails with default options', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0']],
});
const result = await queryEmails('mb-1');
expect(result).toEqual({ ids: ['e1', 'e2'], total: 2 });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ inMailbox: 'mb-1' });
expect(call[1].limit).toBe(50);
expect(call[1].position).toBe(0);
});
it('should support custom sort and limit', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: ['e1'], total: 1 }, '0']],
});
await queryEmails('mb-1', {
limit: 10,
sort: [{ property: 'subject', isAscending: true }],
});
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].limit).toBe(10);
expect(call[1].sort).toEqual([{ property: 'subject', isAscending: true }]);
});
it('should merge additional filter options', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
});
await queryEmails('mb-1', {
filter: { hasKeyword: '$flagged' },
});
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
});
});
describe('getEmails', () => {
it('should fetch emails by id with list properties', async () => {
const emails = [{ id: 'e1', subject: 'Test' }];
mockRequest.mockResolvedValue({
methodResponses: [['Email/get', { list: emails }, '0']],
});
const result = await getEmails(['e1']);
expect(result).toEqual(emails);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].ids).toEqual(['e1']);
expect(call[1].properties).toContain('subject');
expect(call[1].properties).toContain('preview');
expect(call[1].properties).not.toContain('bodyStructure');
});
});
describe('getFullEmail', () => {
it('should fetch full email with body values', async () => {
const email = { id: 'e1', subject: 'Test', htmlBody: [{ partId: 'html' }] };
mockRequest.mockResolvedValue({
methodResponses: [['Email/get', { list: [email] }, '0']],
});
const result = await getFullEmail('e1');
expect(result).toEqual(email);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].fetchHTMLBodyValues).toBe(true);
expect(call[1].fetchTextBodyValues).toBe(true);
expect(call[1].properties).toContain('bodyStructure');
});
});
describe('getThread', () => {
it('should fetch thread by id', async () => {
const thread = { id: 't1', emailIds: ['e1', 'e2'] };
mockRequest.mockResolvedValue({
methodResponses: [['Thread/get', { list: [thread] }, '0']],
});
const result = await getThread('t1');
expect(result).toEqual(thread);
});
});
describe('setEmailKeywords', () => {
it('should update email keywords', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { updated: {} }, '0']] });
await setEmailKeywords('e1', { $seen: true, $flagged: true });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ e1: { keywords: { $seen: true, $flagged: true } } });
});
});
describe('moveEmail', () => {
it('should move email between mailboxes using path patches', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { updated: {} }, '0']] });
await moveEmail('e1', 'inbox', 'archive');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update.e1).toEqual({
'mailboxIds/inbox': null,
'mailboxIds/archive': true,
});
});
});
describe('deleteEmail', () => {
it('should move to trash if not already in trash', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { updated: {} }, '0']] });
await deleteEmail('e1', 'trash', 'inbox');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update.e1).toEqual({
'mailboxIds/inbox': null,
'mailboxIds/trash': true,
});
});
it('should permanently destroy if already in trash', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['Email/set', { destroyed: ['e1'] }, '0']] });
await deleteEmail('e1', 'trash', 'trash');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['e1']);
});
});
describe('searchEmails', () => {
it('should search with text filter', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: ['e1'] }, '0']],
});
const result = await searchEmails('test query');
expect(result).toEqual(['e1']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'test query' });
});
it('should include mailbox filter if specified', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: [] }, '0']],
});
await searchEmails('query', 'mb-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' });
});
});
describe('sendEmail', () => {
it('should create email and submission in one request', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
],
});
await sendEmail(
{
from: [{ email: 'me@example.com' }],
to: [{ email: 'you@example.com' }],
subject: 'Hello',
textBody: 'Hi there',
},
'identity-1',
'sent-mb',
);
expect(mockRequest).toHaveBeenCalledTimes(1);
const calls = mockRequest.mock.calls[0][0];
expect(calls).toHaveLength(2);
expect(calls[0][0]).toBe('Email/set');
expect(calls[1][0]).toBe('EmailSubmission/set');
expect(calls[1][1].create['sub-1'].emailId).toBe('#draft');
});
it('should use htmlBody when provided', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
],
});
await sendEmail(
{
from: [{ email: 'me@example.com' }],
to: [{ email: 'you@example.com' }],
subject: 'Hello',
htmlBody: '<p>Hi there</p>',
},
'identity-1',
'sent-mb',
);
const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft;
expect(emailCreate.htmlBody).toEqual([{ partId: 'html', type: 'text/html' }]);
expect(emailCreate.bodyValues).toEqual({ html: { value: '<p>Hi there</p>' } });
});
it('should set reply headers when inReplyTo is provided', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
],
});
await sendEmail(
{
from: [{ email: 'me@example.com' }],
to: [{ email: 'you@example.com' }],
subject: 'Re: Hello',
textBody: 'replying',
inReplyTo: '<msg-1@example.com>',
references: '<msg-0@example.com> <msg-1@example.com>',
},
'identity-1',
'sent-mb',
);
const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft;
expect(emailCreate['header:In-Reply-To:asText']).toBe('<msg-1@example.com>');
expect(emailCreate['header:References:asText']).toBe('<msg-0@example.com> <msg-1@example.com>');
});
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import { getIdentities, createIdentity, updateIdentity, deleteIdentity } from '../identity';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('identity operations', () => {
describe('getIdentities', () => {
it('should fetch identities', async () => {
const identities = [{ id: 'id-1', name: 'Me', email: 'me@example.com' }];
mockRequest.mockResolvedValue({
methodResponses: [['Identity/get', { list: identities }, '0']],
});
const result = await getIdentities();
expect(result).toEqual(identities);
expect(mockRequest).toHaveBeenCalledWith(
[['Identity/get', { accountId: 'acc-1' }, '0']],
expect.arrayContaining(['urn:ietf:params:jmap:submission']),
);
});
});
describe('createIdentity', () => {
it('should create a new identity', async () => {
const created = { id: 'id-new', name: 'Work', email: 'work@example.com' };
mockRequest.mockResolvedValue({
methodResponses: [['Identity/set', { created: { 'new-identity': created } }, '0']],
});
const result = await createIdentity({ name: 'Work', email: 'work@example.com' });
expect(result).toEqual(created);
});
});
describe('updateIdentity', () => {
it('should update identity fields', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Identity/set', { updated: {} }, '0']],
});
await updateIdentity('id-1', { name: 'Updated Name' });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ 'id-1': { name: 'Updated Name' } });
});
});
describe('deleteIdentity', () => {
it('should destroy an identity', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Identity/set', { destroyed: ['id-1'] }, '0']],
});
await deleteIdentity('id-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['id-1']);
});
});
});
+296
View File
@@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock expo-secure-store before importing the client
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(),
getItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}));
import * as SecureStore from 'expo-secure-store';
import { JMAPClient, AuthenticationError, RateLimitError } from '../jmap-client';
import type { JMAPSession } from '../types';
const MOCK_SESSION: JMAPSession = {
apiUrl: 'https://mail.example.com/jmap/',
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'acc-1',
'urn:ietf:params:jmap:core': 'acc-1',
},
accounts: {
'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false },
},
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
},
state: 'state-1',
};
function mockFetch(responses: Array<{ status: number; json?: any; headers?: Record<string, string> }>) {
let callIndex = 0;
return vi.fn(async () => {
const resp = responses[callIndex++] ?? responses[responses.length - 1];
return {
ok: resp.status >= 200 && resp.status < 300,
status: resp.status,
statusText: resp.status === 200 ? 'OK' : 'Error',
headers: {
get: (name: string) => resp.headers?.[name] ?? null,
},
json: async () => resp.json,
};
});
}
describe('JMAPClient', () => {
let client: JMAPClient;
beforeEach(() => {
client = new JMAPClient();
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('connect', () => {
it('should authenticate and establish session', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
const session = await client.connect('https://mail.example.com', 'user', 'pass');
expect(session).toEqual(MOCK_SESSION);
expect(client.isConnected).toBe(true);
expect(client.accountId).toBe('acc-1');
expect(SecureStore.setItemAsync).toHaveBeenCalled();
});
it('should throw AuthenticationError on 401', async () => {
global.fetch = mockFetch([{ status: 401 }]) as any;
await expect(client.connect('https://mail.example.com', 'user', 'bad'))
.rejects.toThrow(AuthenticationError);
});
it('should throw on non-ok response', async () => {
global.fetch = mockFetch([{ status: 500 }]) as any;
await expect(client.connect('https://mail.example.com', 'user', 'pass'))
.rejects.toThrow('Session discovery failed');
});
it('should strip trailing slashes from server URL', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await client.connect('https://mail.example.com///', 'user', 'pass');
expect(global.fetch).toHaveBeenCalledWith(
'https://mail.example.com/.well-known/jmap',
expect.any(Object),
);
});
it('should use Basic auth header', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Basic ${btoa('user:pass')}`,
}),
}),
);
});
});
describe('connectWithToken', () => {
it('should use Bearer auth', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await client.connectWithToken('https://mail.example.com', 'my-token');
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer my-token',
}),
}),
);
expect(client.isConnected).toBe(true);
});
});
describe('request', () => {
beforeEach(async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
});
it('should make JMAP API request', async () => {
const responseBody = {
methodResponses: [['Mailbox/get', { list: [] }, '0']],
};
global.fetch = mockFetch([{ status: 200, json: responseBody }]) as any;
const result = await client.request([['Mailbox/get', { accountId: 'acc-1' }, '0']]);
expect(result.methodResponses).toHaveLength(1);
expect(result.methodResponses[0][0]).toBe('Mailbox/get');
});
it('should throw AuthenticationError on 401 during request', async () => {
global.fetch = mockFetch([{ status: 401 }]) as any;
await expect(client.request([['Mailbox/get', {}, '0']]))
.rejects.toThrow(AuthenticationError);
});
it('should throw RateLimitError on 429', async () => {
global.fetch = mockFetch([{
status: 429,
headers: { 'Retry-After': '10' },
}]) as any;
try {
await client.request([['Mailbox/get', {}, '0']]);
expect.unreachable('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(RateLimitError);
expect((err as RateLimitError).retryAfterMs).toBe(10000);
}
});
it('should throw on non-ok response', async () => {
global.fetch = mockFetch([{ status: 503 }]) as any;
await expect(client.request([['Mailbox/get', {}, '0']]))
.rejects.toThrow('JMAP request failed: 503');
});
it('should throw if not connected', async () => {
const freshClient = new JMAPClient();
await expect(freshClient.request([['Mailbox/get', {}, '0']]))
.rejects.toThrow('Not connected');
});
it('should send correct request body', async () => {
const responseBody = { methodResponses: [] };
global.fetch = mockFetch([{ status: 200, json: responseBody }]) as any;
await client.request(
[['Mailbox/get', { accountId: 'acc-1' }, '0']],
['urn:ietf:params:jmap:core'],
);
const fetchCall = (global.fetch as any).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(body.using).toEqual(['urn:ietf:params:jmap:core']);
expect(body.methodCalls).toEqual([['Mailbox/get', { accountId: 'acc-1' }, '0']]);
});
});
describe('restoreSession', () => {
it('should restore from stored credentials', async () => {
(SecureStore.getItemAsync as any).mockResolvedValue(
JSON.stringify({ serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' }),
);
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
const restored = await client.restoreSession();
expect(restored).toBe(true);
expect(client.isConnected).toBe(true);
});
it('should return false with no stored credentials', async () => {
(SecureStore.getItemAsync as any).mockResolvedValue(null);
const restored = await client.restoreSession();
expect(restored).toBe(false);
expect(client.isConnected).toBe(false);
});
it('should logout and return false on failure', async () => {
(SecureStore.getItemAsync as any).mockResolvedValue(
JSON.stringify({ serverUrl: 'https://fail.com', username: 'u', password: 'p' }),
);
global.fetch = mockFetch([{ status: 500 }]) as any;
const restored = await client.restoreSession();
expect(restored).toBe(false);
expect(SecureStore.deleteItemAsync).toHaveBeenCalled();
});
});
describe('logout', () => {
it('should clear session and stored credentials', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
await client.logout();
expect(client.isConnected).toBe(false);
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('jmap_credentials');
});
});
describe('hasCapability', () => {
it('should detect capabilities from session', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
expect(client.hasCapability('urn:ietf:params:jmap:mail')).toBe(true);
expect(client.hasCapability('urn:ietf:params:jmap:calendars')).toBe(false);
});
});
describe('accountId resolution', () => {
it('should fall back to core account', async () => {
const session = {
...MOCK_SESSION,
primaryAccounts: {
'urn:ietf:params:jmap:core': 'core-acc',
},
};
global.fetch = mockFetch([{ status: 200, json: session }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
expect(client.accountId).toBe('core-acc');
});
it('should fall back to first account', async () => {
const session = {
...MOCK_SESSION,
primaryAccounts: {},
accounts: { 'only-acc': { name: 'test', isPersonal: true, isReadOnly: false } },
};
global.fetch = mockFetch([{ status: 200, json: session }]) as any;
await client.connect('https://mail.example.com', 'user', 'pass');
expect(client.accountId).toBe('only-acc');
});
it('should throw if no accounts at all', async () => {
const session = {
...MOCK_SESSION,
primaryAccounts: {},
accounts: {},
};
global.fetch = mockFetch([{ status: 200, json: session }]) as any;
await expect(client.connect('https://mail.example.com', 'user', 'pass'))
.rejects.toThrow('No account found');
});
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
currentSession: {
eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
},
authHeader: 'Basic dXNlcjpwYXNz',
request: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import { startPolling } from '../push';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
describe('push operations', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
describe('startPolling', () => {
it('should poll at the specified interval', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Mailbox/get', { state: 'state-1' }, 'm'],
['Email/get', { state: 'state-1' }, 'e'],
],
});
const handler = vi.fn();
const stop = startPolling(handler, 1000);
// First poll
await vi.advanceTimersByTimeAsync(1000);
expect(mockRequest).toHaveBeenCalledTimes(1);
// Second poll with same state - no notification
await vi.advanceTimersByTimeAsync(1000);
expect(handler).not.toHaveBeenCalled();
stop();
});
it('should notify on state change', async () => {
let callCount = 0;
mockRequest.mockImplementation(async () => {
callCount++;
return {
methodResponses: [
['Mailbox/get', { state: callCount === 1 ? 'state-1' : 'state-2' }, 'm'],
['Email/get', { state: 'state-1' }, 'e'],
],
};
});
const handler = vi.fn();
const stop = startPolling(handler, 1000);
// First poll (establishes baseline)
await vi.advanceTimersByTimeAsync(1000);
expect(handler).not.toHaveBeenCalled();
// Second poll (state changed for Mailbox)
await vi.advanceTimersByTimeAsync(1000);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
'@type': 'StateChange',
changed: {
'acc-1': { Mailbox: 'state-2' },
},
});
stop();
});
it('should handle errors silently', async () => {
mockRequest.mockRejectedValue(new Error('Network error'));
const handler = vi.fn();
const stop = startPolling(handler, 1000);
// Should not throw
await vi.advanceTimersByTimeAsync(1000);
expect(handler).not.toHaveBeenCalled();
stop();
});
});
});
+45
View File
@@ -0,0 +1,45 @@
import { jmapClient } from './jmap-client';
export async function uploadBlob(
uri: string,
type: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace('{accountId}', jmapClient.accountId);
// Fetch local file as blob
const fileResponse = await fetch(uri);
const blob = await fileResponse.blob();
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type,
Authorization: (jmapClient as any).authHeader,
},
body: blob,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
return response.json();
}
export function getDownloadUrl(
blobId: string,
name?: string,
type?: string,
): string {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', jmapClient.accountId)
.replace('{blobId}', blobId)
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
}
+90
View File
@@ -0,0 +1,90 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent } from './types';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
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',
];
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId }, '0']],
USING,
);
return res.methodResponses[0][1].list;
}
export async function queryEvents(
calendarIds: string[],
after: string,
before: string,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/query', {
accountId,
filter: { inCalendars: calendarIds, after, before, types: ['Event'] },
sort: [{ property: 'start', isAscending: true }],
limit: 500,
}, '0']],
USING,
);
return res.methodResponses[0][1].ids;
}
export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId,
ids,
properties: CALENDAR_EVENT_PROPERTIES,
}, '0']],
USING,
);
return res.methodResponses[0][1].list;
}
export async function createEvent(
event: Partial<CalendarEvent>,
calendarId: string,
): Promise<CalendarEvent> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', {
accountId,
create: {
'new-event': { ...event, calendarIds: { [calendarId]: true } },
},
}, '0']],
USING,
);
return res.methodResponses[0][1].created['new-event'];
}
export async function updateEvent(
id: string,
changes: Partial<CalendarEvent>,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
USING,
);
}
export async function deleteEvents(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
USING,
);
}
+76
View File
@@ -0,0 +1,76 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { ContactCard, AddressBook } from './types';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
export async function getAddressBooks(): Promise<AddressBook[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/get', { accountId }, '0']],
USING,
);
return res.methodResponses[0][1].list;
}
export async function queryContacts(
filter?: { text?: string; inAddressBook?: string },
limit = 100,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['ContactCard/query', {
accountId,
filter: filter ?? {},
sort: [{ property: 'name', isAscending: true }],
limit,
}, '0']],
USING,
);
return res.methodResponses[0][1].ids;
}
export async function getContacts(ids: string[]): Promise<ContactCard[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['ContactCard/get', { accountId, ids }, '0']],
USING,
);
return res.methodResponses[0][1].list;
}
export async function createContact(
contact: Partial<ContactCard>,
addressBookId: string,
): Promise<ContactCard> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['ContactCard/set', {
accountId,
create: {
'new-contact': { ...contact, addressBookIds: { [addressBookId]: true } },
},
}, '0']],
USING,
);
return res.methodResponses[0][1].created['new-contact'];
}
export async function updateContact(
id: string,
changes: Partial<ContactCard>,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['ContactCard/set', { accountId, update: { [id]: changes } }, '0']],
USING,
);
}
export async function deleteContacts(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['ContactCard/set', { accountId, destroy: ids }, '0']],
USING,
);
}
+204
View File
@@ -0,0 +1,204 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Email, EmailAddress, Mailbox, Thread } from './types';
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
];
const EMAIL_FULL_PROPERTIES = [
...EMAIL_LIST_PROPERTIES,
'bodyStructure', 'textBody', 'htmlBody', 'attachments', 'blobId',
'bcc', 'replyTo', 'sentAt',
];
export async function getMailboxes(): Promise<Mailbox[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
return res.methodResponses[0][1].list;
}
export async function queryEmails(
mailboxId: string,
options?: {
position?: number;
limit?: number;
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
},
): Promise<{ ids: string[]; total: number }> {
const accountId = jmapClient.accountId;
const res = await jmapClient.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,
};
}
export async function getEmails(ids: string[]): Promise<Email[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
return res.methodResponses[0][1].list;
}
export async function getFullEmail(id: string): Promise<Email> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
ids: [id],
properties: EMAIL_FULL_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
maxBodyValueBytes: 512000,
}, '0'],
]);
return res.methodResponses[0][1].list[0];
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Thread/get', { accountId, ids: [threadId] }, '0']],
);
return res.methodResponses[0][1].list[0];
}
export async function setEmailKeywords(
emailId: string,
keywords: Record<string, boolean>,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request([
['Email/set', { accountId, update: { [emailId]: { keywords } } }, '0'],
]);
}
export async function moveEmail(
emailId: string,
fromMailboxId: string,
toMailboxId: string,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request([
['Email/set', {
accountId,
update: {
[emailId]: {
[`mailboxIds/${fromMailboxId}`]: null,
[`mailboxIds/${toMailboxId}`]: true,
},
},
}, '0'],
]);
}
export async function deleteEmail(
emailId: string,
trashMailboxId: string,
currentMailboxId: string,
): Promise<void> {
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
await jmapClient.request([
['Email/set', { accountId, destroy: [emailId] }, '0'],
]);
} else {
await moveEmail(emailId, currentMailboxId, trashMailboxId);
}
}
export async function searchEmails(
query: string,
mailboxId?: string,
limit = 30,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = { text: query };
if (mailboxId) filter.inMailbox = mailboxId;
const res = await jmapClient.request([
['Email/query', {
accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
]);
return res.methodResponses[0][1].ids;
}
export 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,
sentMailboxId: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const emailCreate: Record<string, unknown> = {
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 jmapClient.request(
[
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
['EmailSubmission/set', {
accountId,
create: { 'sub-1': { emailId: '#draft', identityId } },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
}
+39
View File
@@ -0,0 +1,39 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = {};
if (parentId !== undefined) filter.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, filter }, '0']],
USING,
);
return res.methodResponses[0][1].list;
}
export async function createFileNode(
fileNode: Partial<FileNode>,
): Promise<FileNode> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
create: { 'new-file': fileNode },
}, '0']],
USING,
);
return res.methodResponses[0][1].created['new-file'];
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
);
}
+45
View File
@@ -0,0 +1,45 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Identity } from './types';
export async function getIdentities(): Promise<Identity[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Identity/get', { accountId }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
return res.methodResponses[0][1].list;
}
export async function createIdentity(
identity: Partial<Identity>,
): Promise<Identity> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Identity/set', {
accountId,
create: { 'new-identity': identity },
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
return res.methodResponses[0][1].created['new-identity'];
}
export async function updateIdentity(
id: string,
changes: Partial<Identity>,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['Identity/set', { accountId, update: { [id]: changes } }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
}
export async function deleteIdentity(id: string): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['Identity/set', { accountId, destroy: [id] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
}
+206
View File
@@ -0,0 +1,206 @@
import * as SecureStore from 'expo-secure-store';
import type {
JMAPSession,
JMAPMethodCall,
JMAPRequestBody,
JMAPResponseBody,
} from './types';
import { CAPABILITIES } from './types';
const CREDENTIALS_KEY = 'jmap_credentials';
export interface StoredCredentials {
serverUrl: string;
username: string;
password: string;
accessToken?: string;
}
export class JMAPClient {
private session: JMAPSession | null = null;
private credentials: StoredCredentials | null = null;
private _accountId: string | null = null;
get accountId(): string {
if (!this._accountId) {
throw new Error('Not authenticated — call connect() first');
}
return this._accountId;
}
get currentSession(): JMAPSession | null {
return this.session;
}
get isConnected(): boolean {
return this.session !== null && this._accountId !== null;
}
// ── Authentication ────────────────────────────────────
private get authHeader(): string {
if (!this.credentials) throw new Error('No credentials');
if (this.credentials.accessToken) {
return `Bearer ${this.credentials.accessToken}`;
}
const encoded = btoa(`${this.credentials.username}:${this.credentials.password}`);
return `Basic ${encoded}`;
}
async connect(serverUrl: string, username: string, password: string): Promise<JMAPSession> {
// Normalise URL
const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = { serverUrl: baseUrl, username, password };
this.session = await this.fetchSession(baseUrl);
this._accountId = this.resolveAccountId(this.session);
// Persist credentials
await SecureStore.setItemAsync(
CREDENTIALS_KEY,
JSON.stringify(this.credentials),
);
return this.session;
}
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
this.session = await this.fetchSession(baseUrl);
this._accountId = this.resolveAccountId(this.session);
await SecureStore.setItemAsync(
CREDENTIALS_KEY,
JSON.stringify(this.credentials),
);
return this.session;
}
async restoreSession(): Promise<boolean> {
const stored = await SecureStore.getItemAsync(CREDENTIALS_KEY);
if (!stored) return false;
try {
const creds: StoredCredentials = JSON.parse(stored);
this.credentials = creds;
this.session = await this.fetchSession(creds.serverUrl);
this._accountId = this.resolveAccountId(this.session);
return true;
} catch {
await this.logout();
return false;
}
}
async logout(): Promise<void> {
this.session = null;
this.credentials = null;
this._accountId = null;
await SecureStore.deleteItemAsync(CREDENTIALS_KEY);
}
// ── Session Discovery ─────────────────────────────────
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await fetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
if (response.status === 401) {
throw new AuthenticationError('Invalid credentials');
}
if (!response.ok) {
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
private resolveAccountId(session: JMAPSession): string {
// Try mail account first, then any personal account
const mailAccountId = session.primaryAccounts?.[CAPABILITIES.MAIL];
if (mailAccountId) return mailAccountId;
const coreAccountId = session.primaryAccounts?.[CAPABILITIES.CORE];
if (coreAccountId) return coreAccountId;
// Fall back to first account
const accountIds = Object.keys(session.accounts ?? {});
if (accountIds.length > 0) return accountIds[0];
throw new Error('No account found in JMAP session');
}
// ── API Request ───────────────────────────────────────
async request(
methodCalls: JMAPMethodCall[],
using?: string[],
): Promise<JMAPResponseBody> {
if (!this.session) throw new Error('Not connected');
const body: JMAPRequestBody = {
using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL],
methodCalls,
};
const response = await fetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
if (response.status === 401) {
throw new AuthenticationError('Session expired');
}
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const ms = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
throw new RateLimitError(ms);
}
if (!response.ok) {
throw new Error(`JMAP request failed: ${response.status}`);
}
return response.json();
}
// ── Capability Check ──────────────────────────────────
hasCapability(urn: string): boolean {
return Boolean(this.session?.capabilities?.[urn]);
}
}
// ── Error Classes ─────────────────────────────────────────
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
}
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
super('Rate limited by server');
this.name = 'RateLimitError';
this.retryAfterMs = retryAfterMs;
}
}
// Singleton instance
export const jmapClient = new JMAPClient();
+104
View File
@@ -0,0 +1,104 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { StateChange } from './types';
export type StateChangeHandler = (change: StateChange) => void;
/**
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates.
* Returns a cleanup function to close the connection.
*/
export function connectEventSource(
onStateChange: StateChangeHandler,
): (() => void) | null {
const session = jmapClient.currentSession;
if (!session?.eventSourceUrl) return null;
const url = session.eventSourceUrl
.replace('{types}', '*')
.replace('{closeafter}', 'no')
.replace('{ping}', '30');
// Use native EventSource or react-native-sse
const EventSourceImpl = typeof EventSource !== 'undefined'
? EventSource
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: (jmapClient as any).authHeader },
});
const handler = (event: any) => {
try {
const data: StateChange = JSON.parse(
typeof event === 'string' ? event : event.data,
);
onStateChange(data);
} catch {
// Ignore malformed events
}
};
es.addEventListener('state', handler);
return () => {
es.removeEventListener('state', handler);
es.close();
};
}
/**
* Polling fallback when SSE is not available.
* Returns a cleanup function to stop polling.
*/
export function startPolling(
onStateChange: StateChangeHandler,
interval = 5000,
): () => void {
const accountId = jmapClient.accountId;
const stateCache: Record<string, string> = {};
const timer = setInterval(async () => {
try {
const res = await jmapClient.request(
[
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const changed: Record<string, string> = {};
for (const [method, result] of res.methodResponses) {
const type = method.replace('/get', '');
const newState = result.state;
if (newState && stateCache[type] && stateCache[type] !== newState) {
changed[type] = newState;
}
if (newState) stateCache[type] = newState;
}
if (Object.keys(changed).length > 0) {
onStateChange({
'@type': 'StateChange',
changed: { [accountId]: changed },
});
}
} catch {
// Silently retry on next interval
}
}, interval);
return () => clearInterval(timer);
}
/**
* Start real-time updates, preferring SSE with polling fallback.
*/
export function startPushUpdates(
onStateChange: StateChangeHandler,
): () => void {
const cleanup = connectEventSource(onStateChange);
if (cleanup) return cleanup;
return startPolling(onStateChange);
}
+64
View File
@@ -0,0 +1,64 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { EmailAddress } from './types';
export 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,
sentMailboxId: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const emailCreate: Record<string, unknown> = {
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 jmapClient.request(
[
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
['EmailSubmission/set', {
accountId,
create: { 'sub-1': { emailId: '#draft', identityId } },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
}
+270
View File
@@ -0,0 +1,270 @@
// ─── JMAP Session & Core ─────────────────────────────────
export interface JMAPSession {
apiUrl: string;
downloadUrl: string;
uploadUrl: string;
eventSourceUrl: string;
primaryAccounts: Record<string, string>;
accounts: Record<string, JMAPAccountInfo>;
capabilities: Record<string, unknown>;
state: string;
}
export interface JMAPAccountInfo {
name: string;
isPersonal: boolean;
isReadOnly: boolean;
}
export type JMAPMethodCall = [string, Record<string, unknown>, string];
export interface JMAPRequestBody {
using: string[];
methodCalls: JMAPMethodCall[];
}
export interface JMAPResponseBody {
methodResponses: Array<[string, Record<string, any>, string]>;
sessionState?: string;
}
// ─── Email (RFC 8621) ────────────────────────────────────
export interface EmailAddress {
name?: string;
email: string;
}
export interface BodyPart {
partId?: string;
blobId?: string;
type?: string;
name?: string;
disposition?: string;
size?: number;
cid?: string;
}
export interface Attachment {
blobId: string;
type: string;
name?: string;
size?: number;
disposition?: string;
}
export interface Email {
id: string;
threadId: string;
mailboxIds: Record<string, boolean>;
keywords: Record<string, boolean>;
size: number;
receivedAt: string;
sentAt?: string;
from?: EmailAddress[];
to?: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
replyTo?: EmailAddress[];
subject?: string;
preview?: string;
hasAttachment: boolean;
textBody?: BodyPart[];
htmlBody?: BodyPart[];
bodyValues?: Record<string, { value: string; isEncodingProblem?: boolean }>;
attachments?: Attachment[];
blobId?: string;
bodyStructure?: BodyPart;
}
export interface MailboxRights {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
}
export interface Mailbox {
id: string;
name: string;
parentId?: string | null;
role?: string | null;
sortOrder?: number;
totalEmails: number;
unreadEmails: number;
totalThreads: number;
unreadThreads: number;
myRights: MailboxRights;
isSubscribed?: boolean;
}
export interface Thread {
id: string;
emailIds: string[];
}
// ─── Identity & Submission ───────────────────────────────
export interface Identity {
id: string;
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
mayDelete: boolean;
}
export interface EmailSubmission {
id: string;
emailId: string;
identityId: string;
sendAt?: string;
undoStatus?: string;
}
// ─── Contacts (RFC 9553 JSContact) ──────────────────────
export interface ContactCard {
id: string;
uid?: string;
addressBookIds: Record<string, boolean>;
kind?: string;
name?: { components: Array<{ kind: string; value: string }> };
emails?: Record<string, {
address: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}>;
phones?: Record<string, {
number: string;
features?: Record<string, boolean>;
label?: string;
pref?: number;
}>;
addresses?: Record<string, {
components?: any[];
full?: string;
countryCode?: string;
}>;
organizations?: Record<string, {
name: string;
units?: Array<{ name: string }>;
}>;
titles?: Record<string, { name: string }>;
notes?: Record<string, { note: string }>;
media?: Record<string, { kind: string; uri?: string; mediaType?: string }>;
keywords?: Record<string, boolean>;
}
export interface AddressBook {
id: string;
name: string;
isDefault?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean>;
}
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
export interface Participant {
name?: string;
email?: string;
sendTo?: Record<string, string>;
kind?: string;
roles?: Record<string, boolean>;
participationStatus?: string;
expectReply?: boolean;
}
export interface RecurrenceRule {
frequency: string;
interval?: number;
until?: string;
count?: number;
byDay?: Array<{ day: string; nthOfPeriod?: number }>;
byMonth?: string[];
byMonthDay?: number[];
}
export interface Alert {
trigger: { '@type': string; offset?: string; when?: string };
action?: string;
}
export interface CalendarEvent {
id: string;
'@type'?: 'Event' | 'Task';
uid: string;
calendarIds: Record<string, boolean>;
title: string;
description?: string;
start: string;
duration?: string;
timeZone?: string;
showWithoutTime?: boolean;
utcStart?: string;
utcEnd?: string;
status?: string;
freeBusyStatus?: string;
participants?: Record<string, Participant>;
recurrenceRules?: RecurrenceRule[];
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
excludedRecurrenceRules?: RecurrenceRule[];
alerts?: Record<string, Alert>;
links?: Record<string, { href: string; rel?: string }>;
created?: string;
updated?: string;
progress?: string;
due?: string;
}
export interface Calendar {
id: string;
name: string;
color?: string;
isVisible?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean>;
}
// ─── Files (FileNode) ───────────────────────────────────
export interface FileNode {
id: string;
name: string;
parentId?: string | null;
type: string;
blobId?: string;
size?: number;
created?: string;
updated?: string;
}
// ─── Push / SSE ─────────────────────────────────────────
export interface StateChange {
'@type': 'StateChange';
changed: Record<string, Record<string, string>>;
}
// ─── Capability URNs ────────────────────────────────────
export const CAPABILITIES = {
CORE: 'urn:ietf:params:jmap:core',
MAIL: 'urn:ietf:params:jmap:mail',
SUBMISSION: 'urn:ietf:params:jmap:submission',
VACATION: 'urn:ietf:params:jmap:vacationresponse',
CONTACTS: 'urn:ietf:params:jmap:contacts',
CALENDARS: 'urn:ietf:params:jmap:calendars',
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
} as const;
+64
View File
@@ -0,0 +1,64 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
interface BadgeProps {
children: string;
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
style?: ViewStyle;
}
/**
* Matches webmail badge patterns:
* - rounded-full px-2 py-0.5 text-xs font-medium
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
return (
<View style={[styles.base, variantStyles[variant], style]}>
<Text style={[styles.text, textVariantStyles[variant]]}>
{children}
</Text>
</View>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: radius.full,
paddingHorizontal: 8, // px-2
paddingVertical: 2, // py-0.5
alignSelf: 'flex-start',
},
text: {
...typography.caption,
fontWeight: '500',
},
});
const variantStyles: Record<string, ViewStyle> = {
default: {
backgroundColor: colors.surface,
},
primary: {
backgroundColor: colors.primaryBg,
},
destructive: {
backgroundColor: colors.errorBg,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.border,
},
muted: {
backgroundColor: colors.muted,
},
};
const textVariantStyles: Record<string, { color: string }> = {
default: { color: colors.text },
primary: { color: colors.primary },
destructive: { color: colors.error },
outline: { color: colors.text },
muted: { color: colors.mutedForeground },
};
+166
View File
@@ -0,0 +1,166 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator } from 'react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
loading?: boolean;
icon?: React.ReactNode;
style?: ViewStyle;
}
export default function Button({
variant = 'default',
size = 'md',
children,
onPress,
disabled = false,
loading = false,
icon,
style,
}: ButtonProps) {
const isDisabled = disabled || loading;
return (
<Pressable
style={({ pressed }) => [
styles.base,
sizeStyles[size],
variantStyles[variant],
pressed && !isDisabled && pressedStyles[variant],
isDisabled && styles.disabled,
style,
]}
onPress={onPress}
disabled={isDisabled}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? colors.primaryForeground : colors.primary}
/>
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, textVariantStyles[variant], isDisabled && styles.textDisabled]}>
{children}
</Text>
) : (
children
)}
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md, // rounded-md (web)
gap: spacing.sm,
},
text: {
...typography.bodyMedium, // text-sm font-medium (web)
},
disabled: {
opacity: 0.5,
},
textDisabled: {
opacity: 0.5,
},
});
// Matches web: h-9 (sm), h-10 (md), h-11 (lg), h-10 w-10 (icon)
const sizeStyles: Record<ButtonSize, ViewStyle> = {
sm: {
height: componentSizes.buttonSm,
paddingHorizontal: spacing.md, // px-3
},
md: {
height: componentSizes.buttonMd,
paddingHorizontal: spacing.lg, // px-4
paddingVertical: spacing.sm, // py-2
},
lg: {
height: componentSizes.buttonLg,
paddingHorizontal: spacing.xxxl, // px-8
},
icon: {
height: componentSizes.buttonMd,
width: componentSizes.buttonMd,
paddingHorizontal: 0,
},
};
// Matches web button variant classes exactly
const variantStyles: Record<ButtonVariant, ViewStyle> = {
default: {
backgroundColor: colors.primary,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
elevation: 1,
},
ghost: {
backgroundColor: 'transparent',
},
outline: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
},
destructive: {
backgroundColor: colors.error,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
elevation: 1,
},
};
const pressedStyles: Record<ButtonVariant, ViewStyle> = {
default: {
backgroundColor: colors.primaryDark,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
ghost: {
backgroundColor: colors.accent,
},
outline: {
backgroundColor: colors.accent,
},
destructive: {
opacity: 0.9,
},
};
const textVariantStyles: Record<ButtonVariant, TextStyle> = {
default: {
color: colors.primaryForeground,
},
ghost: {
color: colors.text,
},
outline: {
color: colors.text,
},
destructive: {
color: colors.errorForeground,
},
};
+125
View File
@@ -0,0 +1,125 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
interface CardProps {
children: React.ReactNode;
style?: ViewStyle;
}
/**
* Matches webmail card component:
* - bg-card rounded-lg border border-border
* - card-foreground for text
*/
export function Card({ children, style }: CardProps) {
return <View style={[styles.card, style]}>{children}</View>;
}
interface SectionHeaderProps {
title: string;
description?: string;
}
/**
* Matches webmail SettingsSection header:
* - text-lg font-medium for title
* - text-sm text-muted-foreground for description
*/
export function SectionHeader({ title, description }: SectionHeaderProps) {
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{title}</Text>
{description && <Text style={styles.sectionDescription}>{description}</Text>}
</View>
);
}
interface SettingItemRowProps {
label: string;
description?: string;
right?: React.ReactNode;
icon?: React.ReactNode;
iconBg?: string;
bottomBorder?: boolean;
}
/**
* Matches webmail SettingItem:
* - flex items-start justify-between py-3 border-b border-border
* - label: text-sm font-medium text-foreground
* - description: text-xs text-muted-foreground mt-1
*/
export function SettingItemRow({ label, description, right, icon, iconBg, bottomBorder = true }: SettingItemRowProps) {
return (
<View style={[styles.settingRow, bottomBorder && styles.settingRowBorder]}>
{icon && (
<View style={[styles.settingIcon, iconBg ? { backgroundColor: iconBg } : undefined]}>
{icon}
</View>
)}
<View style={styles.settingContent}>
<Text style={styles.settingLabel}>{label}</Text>
{description && <Text style={styles.settingDescription}>{description}</Text>}
</View>
{right && <View style={styles.settingRight}>{right}</View>}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.card,
borderRadius: radius.lg, // rounded-lg
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
sectionHeader: {
marginBottom: spacing.md,
paddingHorizontal: spacing.xs,
},
sectionTitle: {
...typography.h3, // text-lg font-medium
color: colors.text,
},
sectionDescription: {
...typography.body,
color: colors.mutedForeground, // text-muted-foreground
marginTop: 4,
},
settingRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md, // py-3 = 12px
paddingHorizontal: spacing.md,
gap: spacing.md,
},
settingRowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.border, // border-b border-border
},
settingIcon: {
width: 32,
height: 32,
borderRadius: radius.sm,
alignItems: 'center',
justifyContent: 'center',
},
settingContent: {
flex: 1,
paddingRight: spacing.lg, // pr-4
},
settingLabel: {
...typography.bodyMedium, // text-sm font-medium text-foreground
color: colors.text,
},
settingDescription: {
...typography.caption, // text-xs text-muted-foreground
color: colors.mutedForeground,
marginTop: 2,
},
settingRight: {
flexShrink: 0,
},
});
+113
View File
@@ -0,0 +1,113 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import Button from './Button';
import { colors, spacing, radius, typography } from '../theme/tokens';
interface DialogProps {
visible: boolean;
title: string;
message: string;
variant?: 'default' | 'destructive';
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Matches webmail confirm-dialog.tsx:
* - backdrop: bg-black/50 backdrop-blur
* - dialog: bg-background border border-border rounded-lg shadow-xl max-w-md
* - icon badge (destructive): w-10 h-10 rounded-full bg-destructive/10
* - title: text-lg font-semibold text-foreground
* - message: text-sm text-muted-foreground
* - footer: flex justify-end gap-3 px-6 pb-6
*/
export default function Dialog({
visible,
title,
message,
variant = 'default',
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
}: DialogProps) {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.content}>
{variant === 'destructive' && (
<View style={styles.iconBadge}>
<AlertTriangle size={20} color={colors.error} />
</View>
)}
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
</View>
<View style={styles.footer}>
<Button variant="outline" size="sm" onPress={onCancel}>
{cancelText}
</Button>
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} size="sm" onPress={onConfirm}>
{confirmText}
</Button>
</View>
</View>
</View>
</Modal>
);
}
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
},
});
+100
View File
@@ -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 (
<View style={containerStyle}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={[
styles.inputWrapper,
focused && styles.inputFocused,
error && styles.inputError,
props.editable === false && styles.inputDisabled,
]}>
{leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
<RNTextInput
{...props}
style={[styles.input, style]}
placeholderTextColor={colors.textMuted}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
{rightIcon && <View style={styles.iconRight}>{rightIcon}</View>}
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
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,
},
});
+71
View File
@@ -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 (
<View style={[styles.container, style]}>
{options.map((opt) => {
const selected = opt.value === value;
return (
<Pressable
key={opt.value}
style={[styles.option, selected ? styles.optionSelected : styles.optionUnselected]}
onPress={() => onChange(opt.value)}
>
<Text style={[styles.optionText, selected ? styles.textSelected : styles.textUnselected]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
);
}
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,
},
});
+62
View File
@@ -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 (
<Pressable
style={[
styles.track,
value ? styles.trackOn : styles.trackOff,
disabled && styles.disabled,
]}
onPress={() => !disabled && onValueChange(!value)}
accessibilityRole="switch"
accessibilityState={{ checked: value, disabled }}
>
<View
style={[
styles.thumb,
{ transform: [{ translateX: value ? 24 : 4 }] },
]}
/>
</Pressable>
);
}
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,
},
});
+7
View File
@@ -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';
+165
View File
@@ -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 (
<View>
<View style={styles.weekdayRow}>
{WEEKDAYS.map((wd) => (
<Text key={wd} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
<View style={styles.daysGrid}>
{days.map((d, i) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
return (
<View key={i} style={styles.dayCell}>
<View style={[styles.dayNumber, today && styles.todayCircle]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
]}>
{format(d, 'd')}
</Text>
</View>
</View>
);
})}
</View>
</View>
);
}
export default function CalendarScreen() {
const [currentDate, setCurrentDate] = React.useState(new Date());
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<CalendarDays size={24} color={colors.primary} />
<Text style={styles.headerTitle}>Calendar</Text>
</View>
<View style={styles.monthNav}>
<Pressable onPress={() => setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
<ChevronLeft size={20} color={colors.text} />
</Pressable>
<Text style={styles.monthLabel}>{format(currentDate, 'MMMM yyyy')}</Text>
<Pressable onPress={() => setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
<ChevronRight size={20} color={colors.text} />
</Pressable>
</View>
<MonthGrid currentDate={currentDate} />
<View style={styles.todaySection}>
<Text style={styles.todaySectionTitle}>Today's Events</Text>
{MOCK_EVENTS.map((event) => (
<Pressable key={event.id} style={styles.eventCard}>
<View style={[styles.eventDot, { backgroundColor: event.color }]} />
<View style={styles.eventContent}>
<Text style={styles.eventTitle}>{event.title}</Text>
<Text style={styles.eventTime}>{event.time}</Text>
</View>
</Pressable>
))}
</View>
</ScrollView>
);
}
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 },
});
+382
View File
@@ -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 (
<View style={styles.calendarGrid}>
<View style={styles.weekdayRow}>
{WEEKDAYS.map((wd, i) => (
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
<View style={styles.daysGrid}>
{days.map((d, i) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
const selected = isSameDay(d, selectedDate);
const dayEvents = eventDots(d);
return (
<Pressable key={i} style={styles.dayCell} onPress={() => onSelectDate(d)}>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && (
<View style={styles.eventDotsRow}>
{dayEvents.slice(0, 3).map((e, idx) => (
<View key={idx} style={[styles.eventDotSmall, { backgroundColor: e.color }]} />
))}
</View>
)}
</Pressable>
);
})}
</View>
</View>
);
}
function EventCard({ event }: { event: typeof MOCK_EVENTS[0] }) {
return (
<Pressable style={styles.eventCard}>
<View style={[styles.eventColorBar, { backgroundColor: event.color }]} />
<View style={styles.eventBody}>
<View style={styles.eventHeaderRow}>
<Text style={styles.eventTitle}>{event.title}</Text>
{event.allDay && (
<View style={styles.allDayBadge}>
<Text style={styles.allDayText}>All day</Text>
</View>
)}
</View>
{event.time ? (
<View style={styles.eventDetail}>
<Clock size={12} color={colors.textMuted} />
<Text style={styles.eventDetailText}>{event.time}</Text>
</View>
) : null}
{event.location ? (
<View style={styles.eventDetail}>
<MapPin size={12} color={colors.textMuted} />
<Text style={styles.eventDetailText}>{event.location}</Text>
</View>
) : null}
{event.participants > 0 && (
<View style={styles.eventDetail}>
<Users size={12} color={colors.textMuted} />
<Text style={styles.eventDetailText}>{event.participants} participants</Text>
</View>
)}
</View>
</Pressable>
);
}
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<ViewMode>('month');
const selectedEvents = MOCK_EVENTS.filter(e => isSameDay(e.date, selectedDate));
const isSelectedToday = isToday(selectedDate);
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.headerTitle}>{format(currentDate, 'MMMM yyyy')}</Text>
<Text style={styles.headerSubtitle}>
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
</Text>
</View>
<View style={styles.headerActions}>
{/* View mode toggle */}
<View style={styles.viewToggle}>
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
const Icon = mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
const active = viewMode === mode;
return (
<Pressable
key={mode}
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
onPress={() => setViewMode(mode)}
>
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
</Pressable>
);
})}
</View>
<Button variant="default" size="icon" onPress={onCreateEvent} style={styles.addButton}>
<Plus size={18} color={colors.primaryForeground} />
</Button>
</View>
</View>
{/* Month navigation */}
<View style={styles.monthNav}>
<Pressable onPress={() => setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
<ChevronLeft size={20} color={colors.text} />
</Pressable>
<Pressable
onPress={() => { setCurrentDate(new Date()); setSelectedDate(new Date()); }}
>
<Button variant="outline" size="sm" onPress={() => { setCurrentDate(new Date()); setSelectedDate(new Date()); }}>
Today
</Button>
</Pressable>
<Pressable onPress={() => setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
<ChevronRight size={20} color={colors.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Calendar grid */}
<MonthGrid
currentDate={currentDate}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
/>
{/* Events for selected day */}
<View style={styles.eventsSection}>
<View style={styles.eventsSectionHeader}>
<Text style={styles.eventsSectionTitle}>
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
</Text>
<Text style={styles.eventsSectionCount}>
{selectedEvents.length} event{selectedEvents.length !== 1 ? 's' : ''}
</Text>
</View>
{selectedEvents.length > 0 ? (
selectedEvents.map(event => <EventCard key={event.id} event={event} />)
) : (
<View style={styles.emptyState}>
<CalendarDays size={40} color={colors.surfaceActive} />
<Text style={styles.emptyTitle}>No events</Text>
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
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 },
});
+329
View File
@@ -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 (
<View style={styles.chip}>
<Text style={styles.chipText} numberOfLines={1}>
{recipient.name || recipient.email}
</Text>
<Pressable onPress={onRemove} hitSlop={8}>
<X size={12} color={colors.textMuted} />
</Pressable>
</View>
);
}
export default function ComposeScreen({ onClose, onSend, replyTo }: ComposeScreenProps) {
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(
replyTo ? [replyTo.from] : []
);
const [ccVisible, setCcVisible] = React.useState(false);
const [ccRecipients, setCcRecipients] = React.useState<Recipient[]>([]);
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 (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn}>
<X size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>
{replyTo ? 'Reply' : 'New Message'}
</Text>
<View style={styles.headerRight}>
<Pressable style={styles.headerBtn}>
<Paperclip size={20} color={colors.text} />
</Pressable>
<Button
variant="default"
size="sm"
onPress={onSend}
disabled={!canSend}
icon={<Send size={14} color={canSend ? colors.primaryForeground : colors.textMuted} />}
style={!canSend ? styles.sendButtonDisabled : undefined}
>
Send
</Button>
</View>
</View>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.flex} keyboardShouldPersistTaps="handled">
{/* From */}
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>From</Text>
<View style={styles.fieldContent}>
<Text style={styles.fromText}>user@bulwark.mail</Text>
<ChevronDown size={14} color={colors.textMuted} />
</View>
</View>
{/* To */}
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>To</Text>
<View style={styles.recipientField}>
{toRecipients.map((r, i) => (
<RecipientChip
key={i}
recipient={r}
onRemove={() => setToRecipients(prev => prev.filter((_, idx) => idx !== i))}
/>
))}
<TextInput
style={styles.recipientInput}
placeholder="Add recipient..."
placeholderTextColor={colors.textMuted}
value={toInput}
onChangeText={setToInput}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{!ccVisible && (
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
<Text style={styles.ccToggleText}>Cc</Text>
</Pressable>
)}
</View>
{/* Cc (expandable) */}
{ccVisible && (
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>Cc</Text>
<View style={styles.recipientField}>
{ccRecipients.map((r, i) => (
<RecipientChip
key={i}
recipient={r}
onRemove={() => setCcRecipients(prev => prev.filter((_, idx) => idx !== i))}
/>
))}
<TextInput
style={styles.recipientInput}
placeholder="Add Cc..."
placeholderTextColor={colors.textMuted}
value={ccInput}
onChangeText={setCcInput}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
</View>
)}
{/* Subject */}
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>Subject</Text>
<TextInput
style={styles.subjectInput}
placeholder="Email subject"
placeholderTextColor={colors.textMuted}
value={subject}
onChangeText={setSubject}
/>
</View>
{/* Body */}
<TextInput
style={styles.bodyInput}
placeholder="Write your message..."
placeholderTextColor={colors.textMuted}
value={body}
onChangeText={setBody}
multiline
textAlignVertical="top"
/>
{/* Quote (if replying) */}
{replyTo?.body && (
<View style={styles.quoteBlock}>
<View style={styles.quoteBorder} />
<View style={styles.quoteContent}>
<Text style={styles.quoteMeta}>
On {new Date().toLocaleDateString()}, {replyTo.from.name} wrote:
</Text>
<Text style={styles.quoteText} numberOfLines={6}>
{replyTo.body}
</Text>
</View>
</View>
)}
</ScrollView>
{/* Formatting toolbar */}
<View style={styles.formatBar}>
<View style={styles.formatActions}>
<Pressable style={styles.formatBtn}>
<Bold size={18} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Italic size={18} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<List size={18} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Link2 size={18} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Image size={18} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<MoreHorizontal size={18} color={colors.textSecondary} />
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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,
},
});
+110
View File
@@ -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<string, typeof MOCK_CONTACTS> = {};
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 (
<Pressable style={({ pressed }) => [styles.contactRow, pressed && styles.contactRowPressed]}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{item.name.charAt(0)}</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactName}>{item.name}</Text>
<Text style={styles.contactEmail}>{item.email}</Text>
</View>
</Pressable>
);
}
export default function ContactsScreen() {
const sections = React.useMemo(() => groupByLetter(MOCK_CONTACTS), []);
return (
<View style={styles.container}>
<View style={styles.header}>
<Users size={24} color={colors.primary} />
<Text style={styles.headerTitle}>Contacts</Text>
<Text style={styles.headerCount}>{MOCK_CONTACTS.length}</Text>
</View>
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ContactRow item={item} />}
renderSectionHeader={({ section: { title } }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{title}</Text>
</View>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.listContent}
/>
</View>
);
}
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 },
});
+281
View File
@@ -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<string, Contact[]> = {};
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 (
<Pressable
style={({ pressed }) => [styles.contactRow, pressed && styles.contactRowPressed]}
onPress={onPress}
>
<View style={styles.contactAvatar}>
<Text style={styles.contactAvatarText}>{initials}</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactName}>{contact.name}</Text>
<Text style={styles.contactEmail} numberOfLines={1}>{contact.email}</Text>
{contact.company && (
<Text style={styles.contactCompany}>{contact.company}</Text>
)}
</View>
<View style={styles.contactActions}>
{contact.phone && (
<Pressable style={styles.contactActionBtn} hitSlop={8}>
<Phone size={16} color={colors.primary} />
</Pressable>
)}
<Pressable style={styles.contactActionBtn} hitSlop={8}>
<Mail size={16} color={colors.primary} />
</Pressable>
</View>
</Pressable>
);
}
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 (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Contacts</Text>
<Text style={styles.headerCount}>{MOCK_CONTACTS.length}</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => setSearchActive(!searchActive)}
style={styles.headerBtn}
>
<Search size={20} color={colors.text} />
</Pressable>
<Button variant="default" size="icon" onPress={onCreateContact} style={styles.addButton}>
<Plus size={18} color={colors.primaryForeground} />
</Button>
</View>
</View>
{/* Search */}
{searchActive && (
<View style={styles.searchBar}>
<Search size={16} color={colors.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => setSearchQuery('')}>
<X size={16} color={colors.textMuted} />
</Pressable>
)}
</View>
)}
{/* Contact list */}
<SectionList
sections={sections}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<ContactRow contact={item} onPress={() => onContactPress?.(item)} />
)}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{section.title}</Text>
</View>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
stickySectionHeadersEnabled
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyState}>
<UserCircle size={48} color={colors.surfaceActive} />
<Text style={styles.emptyTitle}>No contacts found</Text>
<Text style={styles.emptySubtitle}>Try a different search</Text>
</View>
}
/>
{/* Alphabet index */}
<View style={styles.alphabetIndex}>
{sections.map(s => (
<Text key={s.title} style={styles.alphabetLetter}>{s.title}</Text>
))}
</View>
</SafeAreaView>
);
}
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 },
});
+389
View File
@@ -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 (
<Pressable
style={({ pressed }) => [styles.emailRow, pressed && styles.emailRowPressed]}
onPress={onPress}
>
{/* Avatar */}
<View style={[styles.avatar, { backgroundColor: avatarColor }]}>
<Text style={styles.avatarText}>{initials}</Text>
</View>
{/* Content */}
<View style={styles.emailContent}>
{/* Row 1: Sender + indicators + time */}
<View style={styles.emailHeaderRow}>
<View style={styles.senderRow}>
<Text style={[styles.emailFrom, unread && styles.textUnread]} numberOfLines={1}>
{senderName}
</Text>
{starred && (
<Star size={componentSizes.statusIcon} color={colors.starred} fill={colors.starred} />
)}
{item.hasAttachment && (
<Paperclip size={componentSizes.statusIcon} color={colors.textMuted} />
)}
</View>
<View style={styles.timeAndTag}>
<Text style={styles.emailDate}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
</View>
</View>
{/* Row 2: Subject */}
<View style={styles.subjectRow}>
<Text style={[styles.emailSubject, unread && styles.textBold]} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
</View>
{/* Row 3: Preview */}
<Text style={styles.emailPreview} numberOfLines={2}>
{item.preview}
</Text>
</View>
</Pressable>
);
}
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 (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Pressable onPress={onDrawerOpen} style={styles.headerButton}>
<Menu size={20} color={colors.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>Inbox</Text>
<View style={{ flex: 1 }} />
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.headerLogo}
resizeMode="contain"
/>
</View>
{/* Search bar (always visible) */}
<View style={styles.searchBar}>
<Pressable style={styles.checkboxButton}>
<Square size={18} color={colors.textMuted} />
</Pressable>
<View style={styles.searchInputArea}>
<Search size={16} color={colors.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search mail... (press /)"
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
<Pressable style={styles.filterButton}>
<Filter size={18} color={colors.textMuted} />
</Pressable>
</View>
{/* Email list */}
{loading && emails.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>Loading emails...</Text>
</View>
) : emails.length === 0 ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>No emails</Text>
</View>
) : (
<FlatList
data={emails}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<EmailRow item={item} onPress={() => onEmailPress?.(item)} />
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
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 */}
<Pressable
onPress={onComposePress}
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
>
<SquarePen size={24} color={colors.primaryForeground} />
</Pressable>
</SafeAreaView>
);
}
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 };
+106
View File
@@ -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 (
<Pressable style={({ pressed }) => [styles.emailRow, pressed && styles.emailRowPressed]}>
<View style={styles.emailLeft}>
<View style={[styles.avatar, { backgroundColor: item.unread ? colors.primary : colors.surfaceHover }]}>
<Text style={[styles.avatarText, { color: item.unread ? colors.textInverse : colors.textSecondary }]}>
{item.from.charAt(0)}
</Text>
</View>
</View>
<View style={styles.emailContent}>
<View style={styles.emailHeader}>
<Text style={[styles.emailFrom, item.unread && styles.emailUnread]} numberOfLines={1}>
{item.from}
</Text>
<Text style={styles.emailDate}>{item.date}</Text>
</View>
<Text style={[styles.emailSubject, item.unread && styles.emailUnread]} numberOfLines={1}>
{item.subject}
</Text>
<Text style={styles.emailPreview} numberOfLines={1}>
{item.preview}
</Text>
</View>
{item.starred && (
<Star size={16} color={colors.starred} fill={colors.starred} style={styles.starIcon} />
)}
</Pressable>
);
}
export default function EmailScreen() {
return (
<View style={styles.container}>
<View style={styles.header}>
<Mail size={24} color={colors.primary} />
<Text style={styles.headerTitle}>Inbox</Text>
<Text style={styles.headerCount}>2 unread</Text>
</View>
<FlatList
data={MOCK_EMAILS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <EmailRow item={item} />}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.listContent}
/>
</View>
);
}
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 },
});
+334
View File
@@ -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 (
<View style={[styles.messageCard, isLast && styles.messageCardLast]}>
{/* Message header */}
<Pressable style={styles.messageHeader} onPress={() => setCollapsed(!collapsed)}>
<View style={[styles.messageAvatar, { backgroundColor: isLast ? colors.primary : colors.surfaceActive }]}>
<Text style={[styles.messageAvatarText, { color: isLast ? colors.textInverse : colors.textSecondary }]}>
{message.from.name.charAt(0)}
</Text>
</View>
<View style={styles.messageHeaderContent}>
<View style={styles.messageHeaderRow}>
<Text style={styles.messageFromName} numberOfLines={1}>{message.from.name}</Text>
<Text style={styles.messageDate}>{formatDate(message.date)}</Text>
</View>
<Text style={styles.messageToLine} numberOfLines={1}>
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(', ')}` : ''}
</Text>
</View>
{collapsed ? (
<ChevronDown size={18} color={colors.textMuted} />
) : (
<ChevronUp size={18} color={colors.textMuted} />
)}
</Pressable>
{/* Message body (collapsible) */}
{!collapsed && (
<View style={styles.messageBody}>
<Text style={styles.messageBodyText}>{message.bodyText}</Text>
{/* Attachments */}
{message.attachments && message.attachments.length > 0 && (
<View style={styles.attachmentSection}>
<View style={styles.attachmentHeader}>
<Paperclip size={14} color={colors.textMuted} />
<Text style={styles.attachmentHeaderText}>
{message.attachments.length} attachment{message.attachments.length > 1 ? 's' : ''}
</Text>
</View>
{message.attachments.map((att, idx) => (
<Pressable key={idx} style={styles.attachmentRow}>
<View style={[styles.attachmentIcon, { backgroundColor: getFileTypeColor(att.type) + '20' }]}>
<Text style={[styles.attachmentIconText, { color: getFileTypeColor(att.type) }]}>
{att.type.toUpperCase()}
</Text>
</View>
<View style={styles.attachmentInfo}>
<Text style={styles.attachmentName} numberOfLines={1}>{att.name}</Text>
<Text style={styles.attachmentSize}>{att.size}</Text>
</View>
<Download size={16} color={colors.textMuted} />
</Pressable>
))}
</View>
)}
{/* Quick reply actions (only on last message) */}
{isLast && (
<View style={styles.quickActions}>
<Button variant="outline" size="sm" icon={<Reply size={14} color={colors.primary} />}>
Reply
</Button>
<Button variant="outline" size="sm" icon={<ReplyAll size={14} color={colors.primary} />}>
Reply All
</Button>
<Button variant="outline" size="sm" icon={<Forward size={14} color={colors.primary} />}>
Forward
</Button>
</View>
)}
</View>
)}
</View>
);
}
interface EmailThreadScreenProps {
subject?: string;
onBack?: () => void;
}
export default function EmailThreadScreen({ subject, onBack }: EmailThreadScreenProps) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Toolbar */}
<View style={styles.toolbar}>
<Pressable onPress={onBack} style={styles.toolbarBtn}>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<View style={styles.toolbarActions}>
<Pressable style={styles.toolbarBtn}>
<Archive size={20} color={colors.text} />
</Pressable>
<Pressable style={styles.toolbarBtn}>
<Trash2 size={20} color={colors.text} />
</Pressable>
<Pressable style={styles.toolbarBtn}>
<Star size={20} color={colors.starred} fill={colors.starred} />
</Pressable>
<Pressable style={styles.toolbarBtn}>
<MoreVertical size={20} color={colors.text} />
</Pressable>
</View>
</View>
<ScrollView style={styles.scrollContent} contentContainerStyle={styles.scrollContainer}>
{/* Subject */}
<View style={styles.subjectArea}>
<Text style={styles.subjectText}>
{subject || 'Project update for Q2 — milestones and timeline'}
</Text>
<View style={styles.subjectMeta}>
<View style={styles.labelBadge}>
<Tag size={10} color={colors.tags.blue.dot} />
<Text style={[styles.labelText, { color: colors.tags.blue.dot }]}>Work</Text>
</View>
<Text style={styles.messageCount}>{MOCK_THREAD.length} messages</Text>
</View>
</View>
{/* Messages */}
{MOCK_THREAD.map((msg, index) => (
<MessageCard
key={msg.id}
message={msg}
isLast={index === MOCK_THREAD.length - 1}
/>
))}
</ScrollView>
</SafeAreaView>
);
}
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,
},
});
+173
View File
@@ -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<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<View style={styles.center}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadFiles}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
if (files.length === 0) {
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Files</Text>
</View>
<View style={styles.center}>
<HardDrive size={48} color={colors.textMuted} />
<Text style={styles.emptyText}>No files yet</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Files</Text>
</View>
<FlatList
data={files}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.fileRow}>
{item.type === 'directory' ? (
<Folder size={20} color={colors.primary} />
) : (
<FileText size={20} color={colors.textMuted} />
)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
{item.size != null && (
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
)}
</View>
</View>
)}
/>
</View>
);
}
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,
},
});
+204
View File
@@ -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<React.SetStateAction<string>>) => (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 (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.keyboardView}
>
<View style={styles.content}>
{/* Logo & Branding */}
<View style={styles.branding}>
<View style={styles.logoContainer}>
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.logoImage}
resizeMode="contain"
/>
</View>
<Text style={styles.appName}>Bulwark Mail</Text>
<Text style={styles.tagline}>Secure. Private. Yours.</Text>
</View>
{/* Login Form */}
<View style={styles.form}>
<Input
label="Server URL"
placeholder="https://mail.example.com"
value={serverUrl}
onChangeText={onChangeServerUrl}
autoCapitalize="none"
keyboardType="url"
autoCorrect={false}
/>
<Input
label="Email or Username"
placeholder="you@example.com"
value={email}
onChangeText={onChangeEmail}
autoCapitalize="none"
keyboardType="email-address"
autoCorrect={false}
/>
<Input
label="Password"
placeholder="Enter your password"
value={password}
onChangeText={onChangePassword}
secureTextEntry={!showPassword}
onSubmitEditing={() => {
void handleLogin();
}}
rightIcon={
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={20} color={colors.textMuted} />
) : (
<Eye size={20} color={colors.textMuted} />
)}
</Pressable>
}
/>
<Button
variant="default"
size="lg"
onPress={() => {
void handleLogin();
}}
disabled={!canSubmit}
loading={isLoading}
style={styles.loginButton}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
{/* OAuth divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or continue with</Text>
<View style={styles.dividerLine} />
</View>
{/* OAuth buttons */}
<View style={styles.oauthRow}>
<Button variant="outline" size="md" disabled>
SSO Provider
</Button>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
<Text style={styles.footerLink}>Privacy Policy</Text>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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 },
});
+143
View File
@@ -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 (
<Pressable style={({ pressed }) => [styles.settingRow, pressed && styles.settingRowPressed]}>
<View style={styles.settingIcon}>{icon}</View>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>{title}</Text>
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
</View>
{rightElement || <ChevronRight size={18} color={colors.textMuted} />}
</Pressable>
);
}
export default function SettingsScreen() {
const [darkMode, setDarkMode] = React.useState(false);
const [notifications, setNotifications] = React.useState(true);
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Settings size={24} color={colors.primary} />
<Text style={styles.headerTitle}>Settings</Text>
</View>
<View style={styles.profileCard}>
<View style={styles.profileAvatar}>
<Text style={styles.profileAvatarText}>B</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>Bulwark User</Text>
<Text style={styles.profileEmail}>user@example.com</Text>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<SettingItem
icon={<User size={20} color={colors.primary} />}
title="Account Settings"
subtitle="Manage your account details"
/>
<SettingItem
icon={<Shield size={20} color={colors.primary} />}
title="Privacy & Security"
subtitle="Passwords, encryption, trusted senders"
/>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Preferences</Text>
<SettingItem
icon={<Palette size={20} color={colors.primary} />}
title="Dark Mode"
rightElement={<Switch value={darkMode} onValueChange={setDarkMode} trackColor={{ true: colors.primary }} />}
/>
<SettingItem
icon={<Bell size={20} color={colors.primary} />}
title="Notifications"
rightElement={<Switch value={notifications} onValueChange={setNotifications} trackColor={{ true: colors.primary }} />}
/>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
<Text style={styles.footerText}>Built with Expo + React Native</Text>
</View>
</ScrollView>
);
}
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 },
});
+250
View File
@@ -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 (
<Pressable
style={({ pressed }) => [styles.settingRow, pressed && item.type === 'link' && styles.settingRowPressed]}
>
<View style={[styles.settingIcon, { backgroundColor: item.iconBg }]}>
<Icon size={18} color={item.iconColor} />
</View>
<View style={styles.settingContent}>
<Text style={styles.settingLabel}>{item.label}</Text>
{item.detail && item.type === 'link' && (
<Text style={styles.settingDetail}>{item.detail}</Text>
)}
</View>
{item.type === 'link' && (
<ChevronRight size={18} color={colors.textMuted} />
)}
{item.type === 'toggle' && (
<Switch
value={toggled}
onValueChange={setToggled}
trackColor={{ false: colors.surface, true: colors.primary }}
thumbColor={colors.background}
/>
)}
</Pressable>
);
}
interface SettingsScreenProps {
onLogout?: () => void;
}
export default function SettingsScreen({ onLogout }: SettingsScreenProps) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Profile card */}
<View style={styles.profileCard}>
<View style={styles.profileAvatar}>
<Text style={styles.profileAvatarText}>JD</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{PROFILE.name}</Text>
<Text style={styles.profileEmail}>{PROFILE.email}</Text>
</View>
<View style={styles.planBadge}>
<Text style={styles.planBadgeText}>{PROFILE.plan}</Text>
</View>
</View>
{/* Settings sections */}
{SECTIONS.map(section => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<View style={styles.sectionCard}>
{section.items.map((item, idx) => (
<React.Fragment key={item.id}>
{idx > 0 && <View style={styles.sectionSeparator} />}
<SettingRow item={item} />
</React.Fragment>
))}
</View>
</View>
))}
{/* Sign out */}
<Pressable style={styles.signOutBtn} onPress={onLogout}>
<LogOut size={18} color={colors.error} />
<Text style={styles.signOutText}>Sign Out</Text>
</Pressable>
{/* Version info */}
<Text style={styles.versionText}>Bulwark Mobile v0.0.1</Text>
</ScrollView>
</SafeAreaView>
);
}
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,
},
});
+265
View File
@@ -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<TabGroup, string> = {
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 (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */}
<View style={styles.header}>
<Pressable
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<Settings size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>Settings</Text>
</View>
{/* Tab list — matches webmail mobile: flat grouped list, no cards */}
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
<View style={styles.tabList}>
{groupedTabs.map((group, groupIndex) => (
<View key={group.group}>
{groupIndex > 0 && <View style={styles.groupDivider} />}
<View style={styles.groupHeader}>
<Text style={styles.groupLabel}>{group.label}</Text>
</View>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<Pressable
key={tab.id}
onPress={() => onTabSelect?.(tab.id)}
style={({ pressed }) => [styles.tabItem, pressed && styles.tabItemPressed]}
>
<View style={styles.tabItemLeft}>
<Icon size={16} color={colors.mutedForeground} />
<Text style={styles.tabItemLabel}>{tab.label}</Text>
{tab.experimental && (
<View style={styles.experimentalBadge}>
<Text style={styles.experimentalText}>Experimental</Text>
</View>
)}
</View>
<ChevronRight size={16} color={colors.mutedForeground} />
</Pressable>
);
})}
</View>
))}
</View>
{/* Logout — matches webmail: border-t, destructive text, icon + label */}
<View style={styles.logoutSection}>
<Pressable
onPress={onLogout}
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
>
<LogOut size={16} color={colors.error} />
<Text style={styles.logoutText}>Sign Out</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
}
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,
},
});
+126
View File
@@ -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<typeof vi.fn>;
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
const mockRestore = jmapClient.restoreSession as ReturnType<typeof vi.fn>;
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();
});
});
});
+115
View File
@@ -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<typeof vi.fn>;
const mockQueryEvents = calendarApi.queryEvents as ReturnType<typeof vi.fn>;
const mockGetEvents = calendarApi.getEvents as ReturnType<typeof vi.fn>;
const mockCreateEvent = calendarApi.createEvent as ReturnType<typeof vi.fn>;
const mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>;
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
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');
});
});
});
+100
View File
@@ -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<typeof vi.fn>;
const mockQueryContacts = contactsApi.queryContacts as ReturnType<typeof vi.fn>;
const mockGetContacts = contactsApi.getContacts as ReturnType<typeof vi.fn>;
const mockCreateContact = contactsApi.createContact as ReturnType<typeof vi.fn>;
const mockUpdateContact = contactsApi.updateContact as ReturnType<typeof vi.fn>;
const mockDeleteContacts = contactsApi.deleteContacts as ReturnType<typeof vi.fn>;
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');
});
});
});
+223
View File
@@ -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<typeof vi.fn>;
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
const mockSetKeywords = emailApi.setEmailKeywords as ReturnType<typeof vi.fn>;
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
const mockDeleteEmail = emailApi.deleteEmail as ReturnType<typeof vi.fn>;
const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>;
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: '<p>Hi</p>' } } };
mockGetFullEmail.mockResolvedValue(fullEmail);
const result = await useEmailStore.getState().getEmailDetail('e1');
expect(result).toEqual(fullEmail);
});
});
});
+97
View File
@@ -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<void>;
logout: () => Promise<void>;
restoreSession: () => Promise<boolean>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((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 }),
}));
+78
View File
@@ -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<void>;
fetchEvents: (calendarIds: string[], after: string, before: string) => Promise<void>;
createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
updateEvent: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
deleteEvent: (id: string) => Promise<void>;
reset: () => void;
}
export const useCalendarStore = create<CalendarState>((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,
}),
}));
+78
View File
@@ -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<void>;
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
createContact: (contact: Partial<ContactCard>, addressBookId: string) => Promise<ContactCard>;
updateContact: (id: string, changes: Partial<ContactCard>) => Promise<void>;
deleteContact: (id: string) => Promise<void>;
reset: () => void;
}
export const useContactsStore = create<ContactsState>((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,
}),
}));
+154
View File
@@ -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<void>;
selectMailbox: (mailboxId: string) => Promise<void>;
loadMoreEmails: () => Promise<void>;
refreshEmails: () => Promise<void>;
getEmailDetail: (id: string) => Promise<Email>;
markRead: (emailId: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
toggleStar: (emailId: string, starred: boolean) => Promise<void>;
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise<void>;
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
searchEmails: (query: string) => Promise<Email[]>;
reset: () => void;
}
export const useEmailStore = create<EmailState>((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,
}),
}));
+34
View File
@@ -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<void>;
reset: () => void;
}
export const useSettingsStore = create<SettingsState>((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,
}),
}));
+45
View File
@@ -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;
+194
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/__tests__/**/*.test.ts'],
},
});