Initial commit: React Native JMAP webmail app
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -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 |
|
After Width: | Height: | Size: 64 KiB |
@@ -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 |
|
After Width: | Height: | Size: 55 KiB |
@@ -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 |
|
After Width: | Height: | Size: 55 KiB |
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/__tests__/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||