mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16f04e818 | ||
|
|
d616943c25 | ||
|
|
cf4e3955bf | ||
|
|
bf20c0af58 | ||
|
|
67f7f83621 | ||
|
|
086d398058 | ||
|
|
373a1a60d8 | ||
|
|
f71f219187 | ||
|
|
77b7be03d8 | ||
|
|
9dd9ce8105 | ||
|
|
dd64931be8 | ||
|
|
ac234f288e | ||
|
|
81749dd89c | ||
|
|
beef086d4c | ||
|
|
cfd67398de | ||
|
|
2b15d2dc50 | ||
|
|
9bf2ecdf27 | ||
|
|
67db8a2ac3 | ||
|
|
6846d859fd | ||
|
|
3bdca0133e | ||
|
|
1858d08dd0 | ||
|
|
427d163dc0 | ||
|
|
7e34ba42ef | ||
|
|
886a64171d | ||
|
|
760bf01f4d | ||
|
|
b6d87093b9 | ||
|
|
2a3b1f90dc | ||
|
|
2ed9c29bd5 | ||
|
|
455f26e90a | ||
|
|
e9b93bc6e5 | ||
|
|
c21661ac7a | ||
|
|
00be851611 | ||
|
|
71ff8930a2 | ||
|
|
29b06a3a4f | ||
|
|
2911b8e7ac | ||
|
|
3d4779a689 | ||
|
|
641fde0505 | ||
|
|
6d5195d9fa | ||
|
|
40f9da244f | ||
|
|
c7ea4d757c | ||
|
|
1d89cb38cf | ||
|
|
abbbff2afa | ||
|
|
0a402225f1 | ||
|
|
9bfeb027de | ||
|
|
9a76b22d86 | ||
|
|
b254573237 | ||
|
|
2409a51952 | ||
|
|
37e1b93c48 | ||
|
|
a656a6b56a | ||
|
|
20cdc282c8 | ||
|
|
cec98d89db | ||
|
|
8fa8597e9f | ||
|
|
57f15f6f2e | ||
|
|
cd3fdfa217 | ||
|
|
f8064d4544 | ||
|
|
a8bb83ffe0 | ||
|
|
c37256c54e | ||
|
|
e1d318ebc1 | ||
|
|
2090480bf4 | ||
|
|
329c8a2f0b | ||
|
|
49d1fc62a8 | ||
|
|
d5a336519a | ||
|
|
9c7654acb2 | ||
|
|
346a71de84 | ||
|
|
9a278f1be3 | ||
|
|
cf3f919fa2 | ||
|
|
792b028b7a | ||
|
|
ac84f13b22 | ||
|
|
571b28a9f2 | ||
|
|
a95783723a | ||
|
|
b36e065ebe | ||
|
|
0d7bf3204a | ||
|
|
a76e0fe8c0 | ||
|
|
cee4193e58 | ||
|
|
5ac2a142b5 | ||
|
|
e73dc4e5ba |
@@ -42,6 +42,16 @@ jobs:
|
||||
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||
restore-keys: android-sdk-${{ runner.os }}-
|
||||
|
||||
- name: Compute native cache key input
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
|
||||
if (lock.packages && lock.packages['']) delete lock.packages[''].version;
|
||||
delete lock.version;
|
||||
fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {}));
|
||||
"
|
||||
|
||||
- name: Cache native CMake build outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -50,7 +60,7 @@ jobs:
|
||||
android/app/build/intermediates/cxx
|
||||
android/app/build/intermediates/merged_native_libs
|
||||
android/app/build/intermediates/stripped_native_libs
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
restore-keys: cxx-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
@@ -29,22 +31,44 @@ import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ScheduledScreen from './src/screens/ScheduledScreen';
|
||||
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
|
||||
import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { colors, spacing, typography } from './src/theme/tokens';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { runOfflineSync } from './src/lib/offline-sync';
|
||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { useColors } from './src/theme/colors';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||
|
||||
function navigateToNotificationTap(payload: NotificationTapPayload): void {
|
||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||
if (!navigationRef.isReady()) return;
|
||||
|
||||
// The notification carries the account it was generated for. If the user
|
||||
// has since switched to a different account (or had a different one active
|
||||
// when the notification arrived), opening EmailThread under the active
|
||||
// account would fetch the email from the wrong server and fail.
|
||||
const auth = useAuthStore.getState();
|
||||
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
|
||||
const account = useAccountStore.getState().getAccountById(payload.accountId);
|
||||
if (!account) return; // account was logged out — nothing safe to open.
|
||||
await auth.switchAccount(payload.accountId);
|
||||
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
|
||||
}
|
||||
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
@@ -53,32 +77,37 @@ function navigateToNotificationTap(payload: NotificationTapPayload): void {
|
||||
}
|
||||
|
||||
function LoadingScreen({ message }: { message: string }) {
|
||||
const c = useColors();
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<Text style={styles.loadingText}>{message}</Text>
|
||||
<View style={[styles.loadingContainer, { backgroundColor: c.background }]}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
<Text style={[styles.loadingText, { color: c.textSecondary }]}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
|
||||
const c = useColors();
|
||||
const mailboxes = useEmailStore((state) => state.mailboxes);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const disabledTabStyle = { opacity: 0.4 } as const;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// Matches webmail horizontal NavigationRail: bg-background, border-t border-border,
|
||||
// active uses foreground (white), inactive uses muted-foreground.
|
||||
tabBarActiveTintColor: colors.text,
|
||||
tabBarInactiveTintColor: colors.textSecondary,
|
||||
tabBarActiveTintColor: c.text,
|
||||
tabBarInactiveTintColor: c.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: c.background,
|
||||
borderTopColor: c.border,
|
||||
borderTopWidth: 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
@@ -96,8 +125,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: colors.error,
|
||||
color: colors.primaryForeground,
|
||||
backgroundColor: c.error,
|
||||
color: c.primaryForeground,
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
minWidth: 16,
|
||||
@@ -127,6 +156,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasCalendar) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -134,6 +170,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={ContactsScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasContacts) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -141,6 +184,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasFiles) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -161,6 +211,15 @@ export default function App() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
|
||||
// Resolve the user's theme preference to a concrete light/dark style for the
|
||||
// system status bar. The rest of the app's colors are still hard-coded dark
|
||||
// until the StyleSheet migration to a theme-aware `useColors` hook lands.
|
||||
const themePref = useSettingsStore((state) => state.theme);
|
||||
const systemScheme = useColorScheme();
|
||||
const resolvedScheme: 'light' | 'dark' =
|
||||
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
|
||||
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
|
||||
// Persisted active account is the signal that the user was already signed
|
||||
// in on the previous launch. When present we render the main UI with the
|
||||
// cached mail list instead of the "Restoring session" spinner; the real
|
||||
@@ -175,8 +234,22 @@ export default function App() {
|
||||
|
||||
React.useEffect(() => {
|
||||
void useSettingsStore.getState().hydrate();
|
||||
void useLocaleStore.getState().hydrate();
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
// When the network flips back on while we're authenticated-but-offline
|
||||
// (no live JMAP session), retry the session so the user lands back on
|
||||
// live data without needing to relaunch.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && !useAuthStore.getState().session) {
|
||||
void useAuthStore.getState().retrySession();
|
||||
}
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -190,6 +263,24 @@ export default function App() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Offline mail cache: hydrate the cache index on launch, and kick off a
|
||||
// background sync once we have a live JMAP session and the user has the
|
||||
// feature enabled. Re-runs whenever the user changes the days window.
|
||||
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const haveLiveSession = useAuthStore((s) => s.session != null);
|
||||
React.useEffect(() => {
|
||||
void useOfflineCacheStore.getState().hydrate();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
// Slight delay so cold start doesn't compete with the inbox load.
|
||||
const t = setTimeout(() => {
|
||||
void runOfflineSync({ days: offlineCacheDays });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
@@ -211,11 +302,11 @@ export default function App() {
|
||||
void (async () => {
|
||||
const initial = await getInitialNotificationTap();
|
||||
if (cancelled || !initial) return;
|
||||
navigateToNotificationTap(initial);
|
||||
await navigateToNotificationTap(initial);
|
||||
})();
|
||||
|
||||
const unsubscribe = addNotificationTapListener((payload) => {
|
||||
navigateToNotificationTap(payload);
|
||||
void navigateToNotificationTap(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -225,12 +316,27 @@ export default function App() {
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Re-register the device with the configured relay once authenticated,
|
||||
// and whenever the FCM token rotates.
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down THIS account's subscription so
|
||||
// notifications stop arriving for it. Other logged-in accounts keep their
|
||||
// setups intact.
|
||||
const emailNotificationsEnabled = useSettingsStore(
|
||||
(s) => s.emailNotificationsEnabled,
|
||||
);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
let cancelled = false;
|
||||
const doSetup = async () => {
|
||||
if (!emailNotificationsEnabled) {
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl) return;
|
||||
try {
|
||||
@@ -256,7 +362,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
@@ -305,7 +411,7 @@ export default function App() {
|
||||
if (!hasRestoredSession && !hasPersistedAccount) {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<LoadingScreen message="Loading..." />
|
||||
</>
|
||||
);
|
||||
@@ -314,7 +420,7 @@ export default function App() {
|
||||
if (hasRestoredSession && !isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<LoginScreen />
|
||||
</>
|
||||
);
|
||||
@@ -322,7 +428,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<NavigationContainer ref={navigationRef}>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||
@@ -345,6 +451,8 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
||||
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
||||
<Stack.Screen
|
||||
name="AddAccount"
|
||||
options={{
|
||||
@@ -370,12 +478,10 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background,
|
||||
gap: spacing.md,
|
||||
},
|
||||
loadingText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
# React Native Calendar - Parity Plan
|
||||
|
||||
Bring `repos/react-native/` calendar to parity with the webmail calendar at
|
||||
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus
|
||||
utility libs; mobile port targets ~2,500 lines with feature parity on the
|
||||
essentials and explicit deferrals for things that don't fit a phone UX.
|
||||
|
||||
## Current state
|
||||
|
||||
- `src/screens/CalendarScreenNew.tsx` - month grid + agenda-for-selected-day,
|
||||
all rendering `MOCK_EVENTS`. Doesn't touch the store.
|
||||
- `src/stores/calendar-store.ts` - CRUD wiring exists (`fetchCalendars`,
|
||||
`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
|
||||
`handleStateChange`, no per-calendar visibility, no range tracking.
|
||||
- `src/api/calendar.ts` - JMAP methods wired (`Calendar/get`,
|
||||
`CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`).
|
||||
- `src/api/types.ts` - `CalendarEvent` and `Calendar` types match webmail.
|
||||
- No event modal, no detail view, no week/day view, no sidebar, no recurrence.
|
||||
|
||||
## In scope
|
||||
|
||||
### Phase 1 - Data layer (foundation for everything else)
|
||||
|
||||
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
|
||||
|
||||
- `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
|
||||
`CalendarSidebar` toggle state).
|
||||
- `loadedRange: { after: string; before: string } | null` - track what's fetched
|
||||
so view changes don't re-fetch unnecessarily.
|
||||
- `ensureRange(after, before)` - fetch only if the requested window isn't
|
||||
covered by `loadedRange`; expand the loaded range in place instead of
|
||||
replacing events wholesale, so switching Month → Week doesn't drop data.
|
||||
- `handleStateChange(change)` - subscribe to `Calendar` and `CalendarEvent`
|
||||
typeState keys; on push, refetch calendars and re-query the current window.
|
||||
Already referenced from `App.tsx` push handler - this finally implements it.
|
||||
- `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
|
||||
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
|
||||
|
||||
**`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`:
|
||||
|
||||
- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time
|
||||
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
|
||||
- `parseDuration(iso8601)` - `PT1H30M` → milliseconds.
|
||||
- `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }`
|
||||
handling `showWithoutTime`.
|
||||
- `eventsOnDay(events, day)` - day overlap check (not just start-matches-day).
|
||||
- `getCalendarColor(cal)` - fallback to a deterministic palette when `color`
|
||||
is missing, matching `theme/tokens.ts` `colors.calendar.*`.
|
||||
|
||||
**`src/lib/recurrence-expansion.ts`** (new) - minimal port of webmail's RRULE
|
||||
expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`.
|
||||
Mobile port: same surface, same tests. This is the hard part - allocate ~400
|
||||
lines. Drives correct rendering of recurring events across the visible window.
|
||||
|
||||
### Phase 2 - Views
|
||||
|
||||
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
|
||||
|
||||
- On mount: `fetchCalendars()`, then `ensureRange` for visible window.
|
||||
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone).
|
||||
- Header shows month/week label; prev/next shifts by the current view's step.
|
||||
- "Today" button resets both `currentDate` and `selectedDate`.
|
||||
- FAB opens create modal with `start = selectedDate at next half hour`.
|
||||
|
||||
**`src/components/calendar/MonthView.tsx`** (extract from current screen,
|
||||
~180 lines) - real events, up to 3 colored dots per day, "+N" indicator when
|
||||
more. Tap day → selects + scrolls agenda below. Long-press day → opens create
|
||||
modal with that date.
|
||||
|
||||
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) - port of
|
||||
`calendar-week-view.tsx` (511 lines) trimmed for mobile:
|
||||
|
||||
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
|
||||
- All-day strip at top.
|
||||
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among
|
||||
overlapping events (port webmail's stacking algorithm).
|
||||
- Tap event → detail modal. Long-press empty slot → create modal pre-filled.
|
||||
- Horizontal swipe → prev/next week (react-native-gesture-handler already
|
||||
available via react-navigation).
|
||||
- _Deferred_: drag-to-reschedule, drag-to-resize. Phone targets are too small
|
||||
for it to be better than tap-edit.
|
||||
|
||||
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) - port of
|
||||
`calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
|
||||
days forward. Taps open detail modal.
|
||||
|
||||
### Phase 3 - Event interaction
|
||||
|
||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) - bottom
|
||||
sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines
|
||||
on web - phone version drops the hover card chrome and the inline-edit fields):
|
||||
|
||||
- Header: calendar color bar, title, date/time range (or "All day"), calendar name.
|
||||
- Sections: location, description (linkified), participants with RSVP status,
|
||||
recurrence summary (e.g. "Weekly on Mon, Wed - until 2026-06-01"), reminders.
|
||||
- Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
|
||||
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
|
||||
only" / "This and following" / "All events").
|
||||
|
||||
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) - full-screen
|
||||
modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
|
||||
|
||||
- Title, calendar picker (color swatches), all-day toggle.
|
||||
- Start/end date+time pickers (`@react-native-community/datetimepicker` - add dep).
|
||||
- Location, description (multiline).
|
||||
- Participant input with autocomplete from contacts store (port of
|
||||
`participant-input.tsx`, ~180 lines).
|
||||
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
|
||||
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.)
|
||||
are deferred - covers the 95% case.
|
||||
- Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
|
||||
`Alert.trigger.offset`.
|
||||
- Timezone: default to device TZ; optional override picker hidden behind
|
||||
"Advanced" disclosure.
|
||||
- Save path handles "this instance only" override vs master update for
|
||||
recurring events (write to `recurrenceOverrides[recurrenceId]`).
|
||||
|
||||
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) -
|
||||
direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
|
||||
edit/delete of a recurring instance.
|
||||
|
||||
### Phase 4 - Multi-calendar
|
||||
|
||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) -
|
||||
port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
|
||||
(reuses `SidebarDrawer.tsx`):
|
||||
|
||||
- List of calendars with color swatch + visibility toggle + name.
|
||||
- "My calendars" vs "Subscribed" sections if `myRights` differs.
|
||||
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
|
||||
- Footer: "Add calendar" (deferred to Phase 6).
|
||||
|
||||
**`src/theme/tokens.ts`** - audit `colors.calendar.*` palette against webmail's
|
||||
calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
|
||||
add any missing shades.
|
||||
|
||||
### Phase 5 - Polish
|
||||
|
||||
- Pull-to-refresh on each view → forces re-fetch of current window.
|
||||
- Empty states per view (no calendars yet / no events this month).
|
||||
- Loading skeleton for month grid and week grid.
|
||||
- Error banner when fetch fails (pattern from `EmailListScreen`).
|
||||
- Respect `email-store` push via `handleStateChange` so events update live
|
||||
when another client creates one.
|
||||
|
||||
## Explicitly deferred (out of scope)
|
||||
|
||||
These are in the webmail but don't make sense for a first mobile pass:
|
||||
|
||||
- **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines,
|
||||
`ical-subscription-modal.tsx` 220 lines) - file picker + URL subscription
|
||||
with auth flows. Better as a dedicated settings flow later.
|
||||
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) -
|
||||
JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
|
||||
screen, not bolted onto calendar.
|
||||
- **Drag to reschedule / resize** - mobile tap targets are too small for the
|
||||
fine-grained control the web drag gives; tap-edit is the mobile idiom.
|
||||
- **Quick event input** (`quick-event-input.tsx`) - NLP parser ("Lunch tomorrow
|
||||
at noon"). Neat on desktop, marginal on phone where typing is already slow.
|
||||
- **Event context menu** (`event-context-menu.tsx`) - right-click is desktop-
|
||||
only. Actions move into the detail sheet's action row.
|
||||
- **Mini-calendar** (`mini-calendar.tsx`) - web uses it as a sidebar date
|
||||
picker. Mobile already has the main month view; redundant.
|
||||
- **Birthday calendar auto-derived events** - lower priority; leave the
|
||||
contacts-store integration for a follow-up.
|
||||
|
||||
## Dependencies to add
|
||||
|
||||
- `@react-native-community/datetimepicker` - native date/time pickers.
|
||||
- `react-native-reanimated` is already in (via react-navigation); reuse for
|
||||
bottom-sheet animations on `EventDetailSheet`.
|
||||
- No new calendar library - reimplement RRULE expansion in ~400 lines rather
|
||||
than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
|
||||
|
||||
## File map (new / modified)
|
||||
|
||||
```
|
||||
repos/react-native/src/
|
||||
├── screens/
|
||||
│ └── CalendarScreenNew.tsx [rewrite]
|
||||
├── components/
|
||||
│ └── calendar/ [new directory]
|
||||
│ ├── MonthView.tsx [new]
|
||||
│ ├── WeekView.tsx [new]
|
||||
│ ├── AgendaView.tsx [new]
|
||||
│ ├── EventCard.tsx [new]
|
||||
│ ├── EventDetailSheet.tsx [new]
|
||||
│ ├── EventModal.tsx [new]
|
||||
│ ├── ParticipantInput.tsx [new]
|
||||
│ ├── RecurrenceScopeDialog.tsx [new]
|
||||
│ ├── CalendarSidebarDrawer.tsx [new]
|
||||
│ └── index.ts [new]
|
||||
├── stores/
|
||||
│ └── calendar-store.ts [extend]
|
||||
├── lib/
|
||||
│ ├── calendar-utils.ts [new]
|
||||
│ └── recurrence-expansion.ts [new]
|
||||
└── theme/
|
||||
└── tokens.ts [audit calendar palette]
|
||||
```
|
||||
|
||||
## Testing approach
|
||||
|
||||
- `vitest.config.ts` already exists. Unit tests for:
|
||||
- `recurrence-expansion` (daily/weekly/monthly, count+until, byDay).
|
||||
- `calendar-utils` (duration parse, all-day overlap, DST boundary).
|
||||
- Store: `ensureRange` dedup, `handleStateChange` refetch trigger.
|
||||
- Manual emulator test matrix:
|
||||
- Create event (timed, all-day, recurring), verify server round-trip.
|
||||
- Edit recurring event via scope dialog.
|
||||
- Delete recurring instance vs series.
|
||||
- Toggle calendar visibility, confirm events disappear.
|
||||
- Push update from webmail, confirm mobile refreshes.
|
||||
|
||||
## Build order (suggested)
|
||||
|
||||
1. Phase 1 in full - nothing else works without real data.
|
||||
2. MonthView + EventDetailSheet (read path) - first thing user sees.
|
||||
3. EventModal create path - unblocks manual testing of write path.
|
||||
4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together.
|
||||
5. WeekView - biggest UI, easiest to defer if time runs short.
|
||||
6. EventModal edit + recurrence scope - finishes the edit surface.
|
||||
7. Phase 5 polish.
|
||||
|
||||
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util
|
||||
changes. Each phase is ship-safe on its own - the app stays functional even if
|
||||
we stop at Phase 2.
|
||||
@@ -1,11 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.security.KeyChain
|
||||
import android.security.KeyChainAliasCallback
|
||||
import android.util.Base64
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Bridges Android's KeyChain client-certificate machinery into JS so the
|
||||
* mobile app can authenticate to a reverse proxy that asks for mTLS.
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user
|
||||
* pick a cert installed in Android's system credential store. The
|
||||
* selected alias is persisted in SharedPreferences so the choice
|
||||
* survives app restarts.
|
||||
* 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that
|
||||
* injects the picked cert into the TLS handshake. JS-side `fetch`
|
||||
* can't present a client cert because RN's bundled OkHttp client
|
||||
* doesn't expose its KeyManager hook to userland; this method is
|
||||
* the workaround.
|
||||
*/
|
||||
class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
: ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
|
||||
@Volatile private var cachedFactory: SSLSocketFactory? = null
|
||||
@Volatile private var cachedFactoryForAlias: String? = null
|
||||
|
||||
override fun getName(): String = MODULE_NAME
|
||||
|
||||
// ── alias management ────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getAlias(promise: Promise) {
|
||||
promise.resolve(cachedAlias)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearAlias(promise: Promise) {
|
||||
prefs.edit().remove(PREF_ALIAS).apply()
|
||||
cachedAlias = null
|
||||
invalidateFactory()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system "Choose a certificate" picker. Resolves with the
|
||||
* selected alias, or `null` when the user dismisses the picker.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun pickAlias(host: String?, promise: Promise) {
|
||||
// ReactContextBaseJavaModule no longer exposes a Kotlin synthetic
|
||||
// `currentActivity` property in RN 0.80+ (compiles as an unresolved
|
||||
// reference). The explicit Java-style getter on the application
|
||||
// context is the supported call site.
|
||||
val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity()
|
||||
if (activity == null) {
|
||||
promise.reject("no_activity", "No current activity to host the cert picker")
|
||||
return
|
||||
}
|
||||
val callback = KeyChainAliasCallback { alias ->
|
||||
// Callback runs off the main thread; persist + resolve directly.
|
||||
if (alias.isNullOrBlank()) {
|
||||
promise.resolve(null)
|
||||
return@KeyChainAliasCallback
|
||||
}
|
||||
prefs.edit().putString(PREF_ALIAS, alias).apply()
|
||||
cachedAlias = alias
|
||||
invalidateFactory()
|
||||
promise.resolve(alias)
|
||||
}
|
||||
try {
|
||||
KeyChain.choosePrivateKeyAlias(
|
||||
activity,
|
||||
callback,
|
||||
/* keyTypes */ arrayOf("RSA", "EC"),
|
||||
/* issuers */ null,
|
||||
/* host */ host,
|
||||
/* port */ -1,
|
||||
/* alias */ cachedAlias,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("pick_failed", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── secure fetch ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform an HTTP request that presents the persisted client cert (if
|
||||
* any) during the TLS handshake.
|
||||
*
|
||||
* @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number}
|
||||
*
|
||||
* Resolves with `{status, statusText, headers, bodyBase64}` so JS can
|
||||
* reconstruct a standard `Response`-like object.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun fetchSecure(request: ReadableMap, promise: Promise) {
|
||||
val url = request.getString("url")
|
||||
if (url.isNullOrBlank()) {
|
||||
promise.reject("bad_args", "url is required")
|
||||
return
|
||||
}
|
||||
val method = (request.getString("method") ?: "GET").uppercase()
|
||||
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000
|
||||
val headersMap = request.getMap("headers")
|
||||
val bodyBase64 = request.getString("bodyBase64")
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
|
||||
thread(name = "BulwarkClientCert.fetch") {
|
||||
try {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = timeoutMs
|
||||
readTimeout = timeoutMs
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
if (bodyBytes != null) {
|
||||
doOutput = true
|
||||
setFixedLengthStreamingMode(bodyBytes.size)
|
||||
}
|
||||
}
|
||||
if (conn is HttpsURLConnection && cachedAlias != null) {
|
||||
conn.sslSocketFactory = buildOrReuseSocketFactory()
|
||||
}
|
||||
headersMap?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
val v = h.getString(k)
|
||||
if (v != null) conn.setRequestProperty(k, v)
|
||||
}
|
||||
}
|
||||
conn.connect()
|
||||
if (bodyBytes != null) {
|
||||
conn.outputStream.use { out -> out.write(bodyBytes) }
|
||||
}
|
||||
|
||||
val status = conn.responseCode
|
||||
val statusText = conn.responseMessage ?: ""
|
||||
val responseStream = try {
|
||||
conn.inputStream
|
||||
} catch (_: Exception) {
|
||||
conn.errorStream
|
||||
}
|
||||
val responseBytes = if (responseStream != null) {
|
||||
val buf = ByteArrayOutputStream()
|
||||
responseStream.copyTo(buf)
|
||||
responseStream.close()
|
||||
buf.toByteArray()
|
||||
} else {
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
val out = Arguments.createMap()
|
||||
out.putInt("status", status)
|
||||
out.putString("statusText", statusText)
|
||||
val headers = Arguments.createMap()
|
||||
// headerFields is a `Map<String?, List<String>>`; the null
|
||||
// key is the HTTP status line, which we don't need to copy.
|
||||
for ((name, values) in conn.headerFields) {
|
||||
if (name == null || values == null) continue
|
||||
headers.putString(name, values.joinToString(", "))
|
||||
}
|
||||
out.putMap("headers", headers)
|
||||
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
|
||||
conn.disconnect()
|
||||
promise.resolve(out)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────
|
||||
|
||||
private fun invalidateFactory() {
|
||||
cachedFactory = null
|
||||
cachedFactoryForAlias = null
|
||||
}
|
||||
|
||||
private fun buildOrReuseSocketFactory(): SSLSocketFactory {
|
||||
val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected")
|
||||
val existing = cachedFactory
|
||||
if (existing != null && cachedFactoryForAlias == alias) return existing
|
||||
|
||||
val ctx = reactApplicationContext
|
||||
val km = KeyChainKeyManager(ctx, alias)
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(null as java.security.KeyStore?)
|
||||
val trustManagers = tmf.trustManagers
|
||||
val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager?
|
||||
?: throw IllegalStateException("No X509TrustManager available")
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(arrayOf(km), arrayOf<TrustManager>(tm), null)
|
||||
val factory = sslContext.socketFactory
|
||||
cachedFactory = factory
|
||||
cachedFactoryForAlias = alias
|
||||
return factory
|
||||
}
|
||||
|
||||
/**
|
||||
* X509ExtendedKeyManager that always returns the same KeyChain alias.
|
||||
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
|
||||
* them lazily during the TLS handshake, which is fine since the
|
||||
* fetch already runs off-main-thread.
|
||||
*/
|
||||
private class KeyChainKeyManager(
|
||||
private val context: Context,
|
||||
private val alias: String,
|
||||
) : X509ExtendedKeyManager() {
|
||||
|
||||
override fun chooseClientAlias(
|
||||
keyTypes: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String = alias
|
||||
|
||||
override fun chooseEngineClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
engine: SSLEngine?,
|
||||
): String = alias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
emptyArray()
|
||||
|
||||
override fun chooseServerAlias(
|
||||
keyType: String?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String? = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MODULE_NAME = "BulwarkClientCert"
|
||||
private const val PREFS_NAME = "bulwark_client_cert"
|
||||
private const val PREF_ALIAS = "alias"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
@@ -64,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val emailId = options.getString("emailId")
|
||||
val threadId = options.getString("threadId")
|
||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
@@ -72,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject,
|
||||
emailId, threadId, subject, accountId,
|
||||
)
|
||||
promise.resolve(null)
|
||||
}
|
||||
@@ -93,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
emailId: String?,
|
||||
threadId: String?,
|
||||
subject: String?,
|
||||
accountId: String?,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
@@ -100,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
@@ -148,7 +152,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
conn.readTimeout = 3000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("User-Agent", "bulwark-mobile")
|
||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||
// Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured
|
||||
// server can't OOM the headless task with a multi-megabyte image.
|
||||
// We also sample down to ~256x256 since it'll only render at 192px.
|
||||
val stream = conn.inputStream
|
||||
val bytes = stream.use { input ->
|
||||
val limit = MAX_FAVICON_BYTES
|
||||
val buf = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(8 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val n = input.read(chunk)
|
||||
if (n <= 0) break
|
||||
total += n
|
||||
if (total > limit) return@use null
|
||||
buf.write(chunk, 0, n)
|
||||
}
|
||||
buf.toByteArray()
|
||||
} ?: return null
|
||||
val opts = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||
var sample = 1
|
||||
while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) {
|
||||
sample *= 2
|
||||
}
|
||||
val decodeOpts = BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
}
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -167,6 +200,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
fun removeListeners(count: Int) {}
|
||||
|
||||
companion object {
|
||||
// 1 MiB is plenty for a favicon; anything bigger is a sign of trouble.
|
||||
private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
|
||||
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(BulwarkFcmModule(reactContext))
|
||||
listOf(
|
||||
BulwarkFcmModule(reactContext),
|
||||
BulwarkClientCertModule(reactContext),
|
||||
)
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
|
||||
@@ -17,17 +17,36 @@ object NotificationTapStore {
|
||||
return value
|
||||
}
|
||||
|
||||
// The activity is exported (required by the LAUNCHER intent filter), so
|
||||
// any third-party app on the device can craft a starting intent that
|
||||
// includes these extras. Reject anything that doesn't look like the IDs
|
||||
// our notification builder writes — preventing other apps from
|
||||
// navigating us to a forged email/thread.
|
||||
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
|
||||
// accountId is `username@host` (see generateAccountId), so it needs `.`,
|
||||
// `@`, `+` etc. — cap length and restrict charset to what that helper can
|
||||
// emit. accountId is optional: notifications created before this field
|
||||
// existed still navigate (using the active account as a fallback).
|
||||
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
|
||||
|
||||
fun captureFromIntent(intent: Intent?): TapPayload? {
|
||||
val extras = intent?.extras ?: return null
|
||||
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
|
||||
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
|
||||
val subject = extras.getString(EXTRA_SUBJECT)
|
||||
val payload = TapPayload(emailId, threadId, subject)
|
||||
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
?: return null
|
||||
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
?: return null
|
||||
// Subject is human-readable text and may legitimately contain anything;
|
||||
// cap its length so a hostile launcher can't ship a 1MB string into
|
||||
// the navigation payload.
|
||||
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
|
||||
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
extras.remove(EXTRA_THREAD_ID)
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -35,15 +54,18 @@ object NotificationTapStore {
|
||||
val emailId: String,
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ android.enablePngCrunchInReleaseBuilds=true
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
reactNativeArchitectures=arm64-v8a
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
@@ -45,7 +46,7 @@ module.exports = {
|
||||
web: {
|
||||
favicon: './assets/favicon.png',
|
||||
},
|
||||
plugins: ['expo-secure-store', '@react-native-community/datetimepicker'],
|
||||
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+53
-2
@@ -1,27 +1,31 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.39",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@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-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -2792,6 +2796,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/netinfo": {
|
||||
"version": "11.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
|
||||
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.59"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||
@@ -5205,6 +5218,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
@@ -5245,6 +5267,19 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
|
||||
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rtl-detect": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||
@@ -5384,6 +5419,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
|
||||
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -8789,6 +8834,12 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-detect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.39",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -10,22 +10,28 @@
|
||||
"web": "expo start --web",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"bump": "node scripts/bump-version.mjs"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bump": "node scripts/bump-version.mjs",
|
||||
"sync-locales": "node scripts/sync-locales.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@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-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
|
||||
@@ -54,4 +54,10 @@ gitInherit('commit', '-m', `chore: update version number to ${next}`);
|
||||
gitInherit('tag', '-a', next, '-m', next);
|
||||
gitInherit('push', '--follow-tags');
|
||||
|
||||
execFileSync(
|
||||
'gh',
|
||||
['release', 'create', next, '--title', next, '--generate-notes'],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
|
||||
console.log(`Released ${next}`);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copy locale JSON files from the parent webmail repo into ./locales/.
|
||||
// Run from the RN repo root: `node scripts/sync-locales.mjs`.
|
||||
// In environments where the parent repo isn't checked out (e.g. CI clone of
|
||||
// the standalone RN repo), this is a no-op — vendored files stay as-is.
|
||||
import { readdir, mkdir, copyFile, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const RN_LOCALES = join(__dirname, '..', 'locales');
|
||||
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
|
||||
|
||||
if (!existsSync(WEBMAIL_LOCALES)) {
|
||||
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function copyDir(src, dest) {
|
||||
await mkdir(dest, { recursive: true });
|
||||
for (const entry of await readdir(src, { withFileTypes: true })) {
|
||||
const s = join(src, entry.name);
|
||||
const d = join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(s, d);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
await copyFile(s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name);
|
||||
|
||||
let copied = 0;
|
||||
for (const lang of langs) {
|
||||
const src = join(WEBMAIL_LOCALES, lang);
|
||||
const dest = join(RN_LOCALES, lang);
|
||||
await copyDir(src, dest);
|
||||
copied++;
|
||||
}
|
||||
console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`);
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('expo-file-system', () => ({
|
||||
File: class {
|
||||
constructor(public uri: string) {}
|
||||
async bytes() { return new Uint8Array(); }
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,7 +42,10 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
describe('queryEvents', () => {
|
||||
it('should query events within date range', async () => {
|
||||
it('should send a CalendarEvent/query without server-side filter', async () => {
|
||||
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
|
||||
// filter client-side. Asserting on the absence of `filter` keeps that
|
||||
// contract pinned.
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
||||
});
|
||||
@@ -50,12 +54,9 @@ describe('calendar operations', () => {
|
||||
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'],
|
||||
});
|
||||
expect(call[0]).toBe('CalendarEvent/query');
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
expect(call[1].accountId).toBe('acc-1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContacts,
|
||||
createAddressBook,
|
||||
updateAddressBook,
|
||||
deleteAddressBook,
|
||||
} from '../contacts';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
@@ -50,14 +54,14 @@ describe('contacts operations', () => {
|
||||
expect(result).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('should use default empty filter', async () => {
|
||||
it('should omit filter when none provided', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryContacts();
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({});
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,4 +120,58 @@ describe('contacts operations', () => {
|
||||
expect(call[1].destroy).toEqual(['c1', 'c2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAddressBook', () => {
|
||||
it('creates a book and returns the created entity', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { created: { 'new-book': { id: 'ab-9' } } }, '0']],
|
||||
});
|
||||
|
||||
const book = await createAddressBook('Work');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].create).toEqual({ 'new-book': { name: 'Work' } });
|
||||
expect(book).toEqual({ id: 'ab-9', name: 'Work' });
|
||||
});
|
||||
|
||||
it('throws when the server refuses to create', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { notCreated: { 'new-book': { description: 'nope' } } }, '0']],
|
||||
});
|
||||
await expect(createAddressBook('X')).rejects.toThrow('nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAddressBook', () => {
|
||||
it('forwards only settable properties', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
|
||||
});
|
||||
|
||||
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never);
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAddressBook', () => {
|
||||
it('destroys the book by id', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
|
||||
});
|
||||
|
||||
await deleteAddressBook('ab-1');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].destroy).toEqual(['ab-1']);
|
||||
});
|
||||
|
||||
it('throws when destroy fails', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { notDestroyed: { 'ab-1': { description: 'in use' } } }, '0']],
|
||||
});
|
||||
await expect(deleteAddressBook('ab-1')).rejects.toThrow('in use');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,24 @@ describe('email operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
|
||||
});
|
||||
|
||||
it('should AND-wrap a FilterOperator instead of spreading it', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
|
||||
});
|
||||
|
||||
const userFilter = {
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
|
||||
};
|
||||
await queryEmails('mb-1', { filter: userFilter });
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, userFilter],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmails', () => {
|
||||
@@ -197,7 +215,7 @@ describe('email operations', () => {
|
||||
|
||||
expect(result).toEqual(['e1']);
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ text: 'test query' });
|
||||
expect(call[1].filter).toEqual({ text: 'test* query*' });
|
||||
});
|
||||
|
||||
it('should include mailbox filter if specified', async () => {
|
||||
@@ -208,7 +226,7 @@ describe('email operations', () => {
|
||||
await searchEmails('query', 'mb-1');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' });
|
||||
expect(call[1].filter).toEqual({ text: 'query*', inMailbox: 'mb-1' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+95
-12
@@ -1,4 +1,6 @@
|
||||
import { File } from 'expo-file-system';
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
|
||||
export async function uploadBlob(
|
||||
uri: string,
|
||||
@@ -7,26 +9,107 @@ export async function uploadBlob(
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
const uploadUrl = session.uploadUrl.replace('{accountId}', jmapClient.accountId);
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
|
||||
// Fetch local file as blob
|
||||
const fileResponse = await fetch(uri);
|
||||
const blob = await fileResponse.blob();
|
||||
// Read via the file-system API so this works for both `file://` (image
|
||||
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
|
||||
// the underlying ArrayBuffer rather than the Uint8Array view because some
|
||||
// RN transports stringify typed-array bodies, which silently produces an
|
||||
// empty/garbage upload.
|
||||
const bytes = await new File(uri).bytes();
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
Authorization: (jmapClient as any).authHeader,
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
body: blob,
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
// JMAP servers return either the direct shape `{blobId, type, size}` or
|
||||
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
|
||||
// Stalwart has shipped both depending on version; mirror the webmail's
|
||||
// tolerant parsing so uploads don't silently lose the blobId.
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
|
||||
// `new File(uri).bytes()`, which is overkill when we already have the bytes
|
||||
// (e.g. the empty-blob trick used to create directories on Stalwart).
|
||||
export async function uploadBytes(
|
||||
bytes: Uint8Array,
|
||||
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}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
export function getDownloadUrl(
|
||||
@@ -38,8 +121,8 @@ export function getDownloadUrl(
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
return session.downloadUrl
|
||||
.replace('{accountId}', jmapClient.accountId)
|
||||
.replace('{blobId}', blobId)
|
||||
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(name ?? 'download'))
|
||||
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
|
||||
}
|
||||
|
||||
+139
-9
@@ -8,8 +8,11 @@ const CALENDAR_EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
|
||||
'start', 'duration', 'timeZone', 'showWithoutTime',
|
||||
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
|
||||
'participants', 'alerts', 'recurrenceRules',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRules',
|
||||
'replyTo', 'organizerCalendarAddress', 'sequence',
|
||||
'locations', 'virtualLocations',
|
||||
'progress', 'due', 'priority', 'percentComplete',
|
||||
'links', 'created', 'updated',
|
||||
];
|
||||
|
||||
@@ -69,38 +72,165 @@ export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
|
||||
return all;
|
||||
}
|
||||
|
||||
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
|
||||
// / reply / cancellation emails to participants. We pass it whenever an event
|
||||
// has participants so creating/updating/deleting a meeting notifies attendees.
|
||||
function setArgs(
|
||||
accountId: string,
|
||||
payload: Record<string, unknown>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = { accountId, ...payload };
|
||||
if (sendSchedulingMessages !== undefined) {
|
||||
args.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function createEvent(
|
||||
event: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<CalendarEvent> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', {
|
||||
accountId,
|
||||
[['CalendarEvent/set', setArgs(accountId, {
|
||||
create: {
|
||||
'new-event': { ...event, calendarIds: { [calendarId]: true } },
|
||||
},
|
||||
}, '0']],
|
||||
}, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-event'];
|
||||
const result = methodResult<{
|
||||
created?: Record<string, CalendarEvent>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-event'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-event'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create event');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// Batch-create many events in one CalendarEvent/set. Returns the number created.
|
||||
export async function batchCreateEvents(
|
||||
events: Partial<CalendarEvent>[],
|
||||
calendarId: string,
|
||||
): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
const accountId = jmapClient.accountId;
|
||||
const create: Record<string, Partial<CalendarEvent>> = {};
|
||||
events.forEach((e, i) => {
|
||||
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
|
||||
});
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, create }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ created?: Record<string, unknown> }>(res);
|
||||
return result.created ? Object.keys(result.created).length : 0;
|
||||
}
|
||||
|
||||
export async function updateEvent(
|
||||
id: string,
|
||||
changes: Partial<CalendarEvent>,
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update event');
|
||||
}
|
||||
|
||||
export async function deleteEvents(
|
||||
ids: string[],
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
|
||||
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEvents(ids: string[]): Promise<void> {
|
||||
// RSVP to an invitation: patch the participant's participationStatus via a JSON
|
||||
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
|
||||
export async function rsvpEvent(
|
||||
eventId: string,
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// Escape per RFC 6901: ~ → ~0, / → ~1.
|
||||
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const patch: Record<string, unknown> = {
|
||||
[`participants/${escaped}/participationStatus`]: status,
|
||||
};
|
||||
if (replyTo) patch.replyTo = replyTo;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[eventId];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
|
||||
}
|
||||
|
||||
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
|
||||
export async function parseCalendarBlob(blobId: string): Promise<Partial<CalendarEvent>[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
|
||||
notParsable?: string[];
|
||||
notFound?: string[];
|
||||
}>(res);
|
||||
if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format');
|
||||
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
|
||||
const parsed = result.parsed?.[blobId];
|
||||
if (!parsed) return [];
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
|
||||
export async function createCalendar(
|
||||
name: string,
|
||||
color?: string,
|
||||
): Promise<Calendar> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', {
|
||||
accountId,
|
||||
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
created?: Record<string, Calendar>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-cal'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-cal'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create calendar');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
|
||||
// Stalwart otherwise refuses to delete a non-empty calendar.
|
||||
export async function deleteCalendar(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
|
||||
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise<void> {
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAddressBook(name: string): Promise<AddressBook> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
created?: Record<string, AddressBook>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-book'];
|
||||
if (created) return { ...created, name };
|
||||
const err = result.notCreated?.['new-book'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create address book');
|
||||
}
|
||||
|
||||
export async function updateAddressBook(
|
||||
id: string,
|
||||
updates: Partial<AddressBook>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// Only forward server-settable properties.
|
||||
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (description !== undefined) patch.description = description;
|
||||
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||||
if (isDefault !== undefined) patch.isDefault = isDefault;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
|
||||
}
|
||||
|
||||
export async function deleteAddressBook(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
|
||||
}
|
||||
|
||||
/** Fetch all contact cards that live in a specific address book. */
|
||||
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
|
||||
const ids = await queryContacts({ inAddressBook: addressBookId });
|
||||
return getContacts(ids);
|
||||
}
|
||||
|
||||
+628
-19
@@ -1,6 +1,6 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Email, EmailAddress, Mailbox, Thread } from './types';
|
||||
import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
@@ -22,6 +22,132 @@ export async function getMailboxes(): Promise<Mailbox[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
// Variant that also returns the JMAP `state` token so callers can later issue
|
||||
// Mailbox/changes(sinceState=…) to fetch only what changed.
|
||||
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
}
|
||||
|
||||
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
|
||||
if (ids.length === 0) return { list: [], state: '' };
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId, ids }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
}
|
||||
|
||||
export interface MailboxChangesResult {
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: string[];
|
||||
updated: string[];
|
||||
destroyed: string[];
|
||||
}
|
||||
|
||||
// Returns null when the server can't compute the diff (typically
|
||||
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
|
||||
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/changes', { accountId, sinceState }, '0']],
|
||||
);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldState: body.oldState as string,
|
||||
newState: body.newState as string,
|
||||
hasMoreChanges: Boolean(body.hasMoreChanges),
|
||||
created: (body.created as string[]) ?? [],
|
||||
updated: (body.updated as string[]) ?? [],
|
||||
destroyed: (body.destroyed as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMailbox(
|
||||
data: { name: string; parentId?: string | null },
|
||||
): Promise<string> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const cid = 'new-mailbox';
|
||||
const res = await jmapClient.request([
|
||||
['Mailbox/set', {
|
||||
accountId,
|
||||
create: {
|
||||
[cid]: {
|
||||
name: data.name,
|
||||
parentId: data.parentId ?? null,
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const result = res.methodResponses[0][1];
|
||||
if (result.created?.[cid]?.id) return result.created[cid].id as string;
|
||||
const failure = result.notCreated?.[cid] as
|
||||
| { type?: string; description?: string; properties?: string[] }
|
||||
| undefined;
|
||||
throw new Error(
|
||||
failure
|
||||
? `${failure.type ?? 'create failed'}${failure.description ? `: ${failure.description}` : ''}`
|
||||
: 'Mailbox create returned no id',
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateMailbox(
|
||||
id: string,
|
||||
changes: { name?: string; parentId?: string | null },
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
|
||||
]);
|
||||
const failure = res.methodResponses[0][1].notUpdated?.[id] as
|
||||
| { type?: string; description?: string }
|
||||
| undefined;
|
||||
if (failure) {
|
||||
throw new Error(
|
||||
`${failure.type ?? 'update failed'}${failure.description ? `: ${failure.description}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMailbox(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Mailbox/set', { accountId, destroy: [id] }, '0'],
|
||||
]);
|
||||
const failure = res.methodResponses[0][1].notDestroyed?.[id] as
|
||||
| { type?: string; description?: string }
|
||||
| undefined;
|
||||
if (failure) {
|
||||
throw new Error(
|
||||
`${failure.type ?? 'delete failed'}${failure.description ? `: ${failure.description}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMailboxQueryFilter(
|
||||
mailboxId: string,
|
||||
userFilter: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const inMailbox = { inMailbox: mailboxId };
|
||||
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
|
||||
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
|
||||
// produces a hybrid object that servers reduce to the FilterCondition,
|
||||
// silently dropping the operator's conditions (e.g. the "unread" toggle).
|
||||
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
|
||||
if ('operator' in userFilter) {
|
||||
return { operator: 'AND', conditions: [inMailbox, userFilter] };
|
||||
}
|
||||
return { ...inMailbox, ...userFilter };
|
||||
}
|
||||
|
||||
export async function queryEmails(
|
||||
mailboxId: string,
|
||||
options?: {
|
||||
@@ -30,21 +156,69 @@ export async function queryEmails(
|
||||
sort?: Array<{ property: string; isAscending: boolean }>;
|
||||
filter?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ ids: string[]; total: number }> {
|
||||
): Promise<{ ids: string[]; total: number; queryState?: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
filter: { inMailbox: mailboxId, ...options?.filter },
|
||||
filter,
|
||||
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
|
||||
position: options?.position ?? 0,
|
||||
limit: options?.limit ?? 50,
|
||||
calculateTotal: true,
|
||||
}, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return {
|
||||
ids: res.methodResponses[0][1].ids,
|
||||
total: res.methodResponses[0][1].total,
|
||||
ids: body.ids,
|
||||
total: body.total,
|
||||
queryState: body.queryState as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmailQueryChangesResult {
|
||||
oldQueryState: string;
|
||||
newQueryState: string;
|
||||
total: number;
|
||||
removed: string[];
|
||||
added: Array<{ id: string; index: number }>;
|
||||
}
|
||||
|
||||
// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox"
|
||||
// query. Returns null when the server replies with `cannotCalculateChanges`
|
||||
// (or any other error) — caller should fall back to a fresh Email/query.
|
||||
export async function getEmailQueryChanges(
|
||||
mailboxId: string,
|
||||
sinceQueryState: string,
|
||||
options?: {
|
||||
sort?: Array<{ property: string; isAscending: boolean }>;
|
||||
filter?: Record<string, unknown>;
|
||||
upToId?: string;
|
||||
maxChanges?: number;
|
||||
},
|
||||
): Promise<EmailQueryChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const args: Record<string, unknown> = {
|
||||
accountId,
|
||||
filter,
|
||||
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
|
||||
sinceQueryState,
|
||||
calculateTotal: true,
|
||||
};
|
||||
if (options?.upToId) args.upToId = options.upToId;
|
||||
if (options?.maxChanges) args.maxChanges = options.maxChanges;
|
||||
|
||||
const res = await jmapClient.request([['Email/queryChanges', args, '0']]);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldQueryState: body.oldQueryState as string,
|
||||
newQueryState: body.newQueryState as string,
|
||||
total: (body.total as number) ?? 0,
|
||||
removed: (body.removed as string[]) ?? [],
|
||||
added: (body.added as Array<{ id: string; index: number }>) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +230,59 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
// Returns the Email/get response with the JMAP `state` token. Used by the
|
||||
// store so we can later issue Email/changes(sinceState=…) for incremental
|
||||
// updates instead of re-fetching the full list.
|
||||
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
|
||||
if (ids.length === 0) {
|
||||
// Email/get with an empty id list still returns a state token; useful for
|
||||
// priming the store after an empty mailbox query.
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: [], state: body.state as string };
|
||||
}
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Email[], state: body.state as string };
|
||||
}
|
||||
|
||||
export interface EmailChangesResult {
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: string[];
|
||||
updated: string[];
|
||||
destroyed: string[];
|
||||
}
|
||||
|
||||
// Returns null when the server replies with `cannotCalculateChanges` or any
|
||||
// other error response; caller should treat that as "rebuild from scratch".
|
||||
export async function getEmailChanges(
|
||||
sinceState: string,
|
||||
maxChanges?: number,
|
||||
): Promise<EmailChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId, sinceState };
|
||||
if (maxChanges) args.maxChanges = maxChanges;
|
||||
const res = await jmapClient.request([['Email/changes', args, '0']]);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldState: body.oldState as string,
|
||||
newState: body.newState as string,
|
||||
hasMoreChanges: Boolean(body.hasMoreChanges),
|
||||
created: (body.created as string[]) ?? [],
|
||||
updated: (body.updated as string[]) ?? [],
|
||||
destroyed: (body.destroyed as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFullEmail(id: string): Promise<Email> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
@@ -74,6 +301,26 @@ export async function getFullEmail(id: string): Promise<Email> {
|
||||
return email;
|
||||
}
|
||||
|
||||
// Batch variant for offline sync. JMAP servers cap how many objects can be
|
||||
// returned in a single Email/get; the caller should chunk to that ceiling
|
||||
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
|
||||
export async function getFullEmails(ids: string[]): Promise<Email[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids,
|
||||
properties: EMAIL_FULL_PROPERTIES,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchTextBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 512000,
|
||||
}, '0'],
|
||||
]);
|
||||
return res.methodResponses[0][1].list as Email[];
|
||||
}
|
||||
|
||||
export async function getThread(threadId: string): Promise<Thread> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
@@ -111,6 +358,188 @@ export async function moveEmail(
|
||||
]);
|
||||
}
|
||||
|
||||
// Move several emails from one mailbox to another in a single Email/set.
|
||||
export async function moveEmails(
|
||||
ids: string[],
|
||||
fromMailboxId: string,
|
||||
toMailboxId: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const id of ids) {
|
||||
update[id] = {
|
||||
[`mailboxIds/${fromMailboxId}`]: null,
|
||||
[`mailboxIds/${toMailboxId}`]: true,
|
||||
};
|
||||
}
|
||||
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
|
||||
}
|
||||
|
||||
// Batch delete: destroy outright when already in trash, otherwise move to trash.
|
||||
export async function deleteEmails(
|
||||
ids: string[],
|
||||
trashMailboxId: string,
|
||||
currentMailboxId: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
if (currentMailboxId === trashMailboxId) {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
|
||||
} else {
|
||||
await moveEmails(ids, currentMailboxId, trashMailboxId);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply keyword maps to several emails in one round-trip.
|
||||
export async function setKeywordsForEmails(
|
||||
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, { keywords: Record<string, boolean> }> = {};
|
||||
for (const u of updates) update[u.id] = { keywords: u.keywords };
|
||||
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
|
||||
}
|
||||
|
||||
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
|
||||
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
|
||||
// the entire map, so we don't need to compute a diff against the current state.
|
||||
export async function restoreEmailMailboxes(
|
||||
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||||
for (const item of items) {
|
||||
const onlyTrue: Record<string, true> = {};
|
||||
for (const [id, present] of Object.entries(item.mailboxIds)) {
|
||||
if (present) onlyTrue[id] = true;
|
||||
}
|
||||
update[item.id] = { mailboxIds: onlyTrue };
|
||||
}
|
||||
await jmapClient.request([
|
||||
['Email/set', { accountId, update }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Archive one or more emails into the archive mailbox, optionally auto-sorting
|
||||
// into year or year/month subfolders. Mirrors the webmail implementation in
|
||||
// lib/jmap/client.ts so behavior stays in sync between platforms.
|
||||
export async function archiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
|
||||
if (mode === 'single') {
|
||||
const updates = Object.fromEntries(
|
||||
emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]),
|
||||
);
|
||||
await jmapClient.request([
|
||||
['Email/set', { accountId, update: updates }, '0'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
type Dest = { year: string; month?: string };
|
||||
const destFor = new Map<string, Dest>();
|
||||
for (const e of emails) {
|
||||
const d = new Date(e.receivedAt);
|
||||
const year = d.getFullYear().toString();
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
|
||||
}
|
||||
|
||||
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
|
||||
const yearIdFor = new Map<string, string>();
|
||||
const monthIdFor = new Map<string, string>();
|
||||
const createEntries: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
const findExisting = (name: string, parentId: string) =>
|
||||
existingMailboxes.find(
|
||||
(m) => m.name === name && m.parentId === parentId,
|
||||
);
|
||||
|
||||
for (const dest of destFor.values()) {
|
||||
if (!yearIdFor.has(dest.year)) {
|
||||
const existing = findExisting(dest.year, archiveMailboxId);
|
||||
if (existing) {
|
||||
yearIdFor.set(dest.year, existing.id);
|
||||
} else {
|
||||
const cid = `year-${dest.year}`;
|
||||
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
|
||||
yearIdFor.set(dest.year, `#${cid}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'month' && dest.month) {
|
||||
const monthKey = `${dest.year}/${dest.month}`;
|
||||
if (!monthIdFor.has(monthKey)) {
|
||||
const yearRef = yearIdFor.get(dest.year)!;
|
||||
const existingMonth = yearRef.startsWith('#')
|
||||
? undefined
|
||||
: findExisting(dest.month, yearRef);
|
||||
if (existingMonth) {
|
||||
monthIdFor.set(monthKey, existingMonth.id);
|
||||
} else {
|
||||
const cid = `month-${dest.year}-${dest.month}`;
|
||||
createEntries[cid] = { name: dest.month, parentId: yearRef };
|
||||
monthIdFor.set(monthKey, `#${cid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||||
for (const [emailId, dest] of destFor.entries()) {
|
||||
const destId = mode === 'month' && dest.month
|
||||
? monthIdFor.get(`${dest.year}/${dest.month}`)!
|
||||
: yearIdFor.get(dest.year)!;
|
||||
updates[emailId] = { mailboxIds: { [destId]: true } };
|
||||
}
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
const hasCreates = Object.keys(createEntries).length > 0;
|
||||
if (hasCreates) {
|
||||
methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']);
|
||||
}
|
||||
methodCalls.push(['Email/set', { accountId, update: updates }, String(methodCalls.length)]);
|
||||
|
||||
const response = await jmapClient.request(methodCalls);
|
||||
|
||||
if (hasCreates) {
|
||||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||||
const notCreated = mailboxResult?.notCreated as
|
||||
| Record<string, { type?: string; properties?: string[]; description?: string }>
|
||||
| undefined;
|
||||
const failures = notCreated ? Object.entries(notCreated) : [];
|
||||
if (failures.length > 0) {
|
||||
const [cid, err] = failures[0];
|
||||
const parts = [err.type || 'unknown'];
|
||||
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err.description) parts.push(err.description);
|
||||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const emailIdx = hasCreates ? 1 : 0;
|
||||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||||
const notUpdated = emailResult?.notUpdated as
|
||||
| Record<string, { type?: string; description?: string }>
|
||||
| undefined;
|
||||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||||
if (emailFailures.length > 0) {
|
||||
const [id, err] = emailFailures[0];
|
||||
throw new Error(
|
||||
`Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEmail(
|
||||
emailId: string,
|
||||
trashMailboxId: string,
|
||||
@@ -164,6 +593,24 @@ export async function queryEmailsByFilter(
|
||||
return res.methodResponses[0][1].ids;
|
||||
}
|
||||
|
||||
export interface OutgoingAttachment {
|
||||
blobId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
/** True when the message was deferred (HOLDFOR / FUTURERELEASE). */
|
||||
scheduled: boolean;
|
||||
/** ISO timestamp the server resolved for a deferred send, when known. */
|
||||
sendAt?: string;
|
||||
emailId?: string;
|
||||
emailSubmissionId?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
email: {
|
||||
from: EmailAddress[];
|
||||
@@ -173,13 +620,17 @@ export async function sendEmail(
|
||||
subject: string;
|
||||
htmlBody?: string;
|
||||
textBody?: string;
|
||||
attachments?: Array<{ blobId: string; type: string; name: string }>;
|
||||
attachments?: OutgoingAttachment[];
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
},
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
): Promise<void> {
|
||||
// When > 0 the message is held for this many seconds before delivery via the
|
||||
// SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later"
|
||||
// scheduling and the global send-delay (undo-send) window.
|
||||
holdForSeconds?: number,
|
||||
): Promise<SendEmailResult> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: email.from,
|
||||
@@ -191,21 +642,29 @@ export async function sendEmail(
|
||||
keywords: { $seen: true },
|
||||
};
|
||||
|
||||
const bodyValues: Record<string, { value: string }> = {};
|
||||
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 ?? '' } };
|
||||
bodyValues.html = { value: email.htmlBody };
|
||||
}
|
||||
if (email.textBody || !email.htmlBody) {
|
||||
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
||||
bodyValues.text = { value: email.textBody ?? '' };
|
||||
}
|
||||
emailCreate.bodyValues = bodyValues;
|
||||
|
||||
if (email.attachments?.length) {
|
||||
emailCreate.attachments = email.attachments.map((a) => ({
|
||||
blobId: a.blobId,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
disposition: 'attachment',
|
||||
}));
|
||||
emailCreate.attachments = email.attachments.map((a) => {
|
||||
const part: Record<string, unknown> = {
|
||||
blobId: a.blobId,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
disposition: a.disposition ?? 'attachment',
|
||||
};
|
||||
if (a.size != null) part.size = a.size;
|
||||
if (a.cid) part.cid = a.cid;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
if (email.inReplyTo) {
|
||||
@@ -213,14 +672,164 @@ export async function sendEmail(
|
||||
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
|
||||
}
|
||||
|
||||
await jmapClient.request(
|
||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
||||
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
||||
// server derive mailFrom from the Identity, dropping our parameter).
|
||||
if (holdForSeconds && holdForSeconds > 0) {
|
||||
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||
.map((r) => r.email.trim())
|
||||
.filter(Boolean)
|
||||
.map((address) => ({ email: address }));
|
||||
submissionCreate.envelope = {
|
||||
mailFrom: {
|
||||
email: email.from[0]?.email,
|
||||
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
|
||||
},
|
||||
rcptTo,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId,
|
||||
create: { 'sub-1': { emailId: '#draft', identityId } },
|
||||
create: { 'sub-1': submissionCreate },
|
||||
}, '1'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
|
||||
let emailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let sendAt: string | undefined;
|
||||
for (const [methodName, result] of res.methodResponses) {
|
||||
if (methodName.endsWith('/error')) {
|
||||
throw new Error((result as { description?: string }).description ?? 'Send failed');
|
||||
}
|
||||
if (methodName === 'Email/set') {
|
||||
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.draft;
|
||||
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message');
|
||||
emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
|
||||
}
|
||||
if (methodName === 'EmailSubmission/set') {
|
||||
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1'];
|
||||
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message');
|
||||
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
|
||||
emailSubmissionId = created?.id;
|
||||
sendAt = created?.sendAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scheduled: !!(holdForSeconds && holdForSeconds > 0),
|
||||
sendAt,
|
||||
emailId,
|
||||
emailSubmissionId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
emailSubmissionId: string;
|
||||
emailId: string;
|
||||
identityId: string;
|
||||
threadId?: string;
|
||||
sendAt: string;
|
||||
undoStatus?: string;
|
||||
subject?: string;
|
||||
to?: EmailAddress[];
|
||||
from?: EmailAddress[];
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
// List pending (not-yet-delivered, not cancelled) scheduled submissions whose
|
||||
// send time is still in the future, joined with a light Email/get so the UI
|
||||
// can show subject/recipients. Empty when the server lacks FUTURERELEASE.
|
||||
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
|
||||
if (!jmapClient.hasDelayedSend()) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const now = Date.now();
|
||||
|
||||
const queryRes = await jmapClient.request(
|
||||
[['EmailSubmission/query', { accountId, limit: 200 }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
const [queryName, queryBody] = queryRes.methodResponses[0];
|
||||
if (queryName.endsWith('/error')) return [];
|
||||
const ids = (queryBody.ids as string[]) ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const subRes = await jmapClient.request(
|
||||
[['EmailSubmission/get', {
|
||||
accountId,
|
||||
ids,
|
||||
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'],
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
const submissions = ((subRes.methodResponses[0][1].list as Array<{
|
||||
id: string;
|
||||
emailId: string;
|
||||
identityId: string;
|
||||
threadId?: string;
|
||||
sendAt?: string;
|
||||
undoStatus?: string;
|
||||
}>) ?? []).filter((s) => {
|
||||
if (s.undoStatus !== 'pending' || !s.sendAt) return false;
|
||||
const t = new Date(s.sendAt).getTime();
|
||||
return Number.isFinite(t) && t > now;
|
||||
});
|
||||
if (submissions.length === 0) return [];
|
||||
|
||||
const emailIds = Array.from(new Set(submissions.map((s) => s.emailId)));
|
||||
const emailRes = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: emailIds,
|
||||
properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'],
|
||||
}, '0'],
|
||||
]);
|
||||
const emailById = new Map(
|
||||
((emailRes.methodResponses[0][1].list as Email[]) ?? []).map((e) => [e.id, e]),
|
||||
);
|
||||
|
||||
return submissions
|
||||
.map((s): ScheduledEmail | null => {
|
||||
const e = emailById.get(s.emailId);
|
||||
if (!s.sendAt) return null;
|
||||
return {
|
||||
emailSubmissionId: s.id,
|
||||
emailId: s.emailId,
|
||||
identityId: s.identityId,
|
||||
threadId: s.threadId ?? e?.threadId,
|
||||
sendAt: s.sendAt,
|
||||
undoStatus: s.undoStatus,
|
||||
subject: e?.subject,
|
||||
to: e?.to,
|
||||
from: e?.from,
|
||||
preview: e?.preview,
|
||||
};
|
||||
})
|
||||
.filter((s): s is ScheduledEmail => s !== null)
|
||||
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
}
|
||||
|
||||
// Cancel a pending scheduled send. The held message copy stays in Sent; only
|
||||
// delivery is stopped (matches the webmail behaviour).
|
||||
export async function cancelScheduledSend(emailSubmissionId: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['EmailSubmission/set', {
|
||||
accountId,
|
||||
update: { [emailSubmissionId]: { undoStatus: 'canceled' } },
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
const failure = res.methodResponses[0][1].notUpdated?.[emailSubmissionId] as
|
||||
| { type?: string; description?: string }
|
||||
| undefined;
|
||||
if (failure) {
|
||||
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
|
||||
}
|
||||
}
|
||||
|
||||
+157
-8
@@ -1,19 +1,68 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
|
||||
|
||||
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
|
||||
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
|
||||
// mark a directory; everything else is a MIME type.
|
||||
const DIRECTORY_TYPES = new Set([
|
||||
'd',
|
||||
'application/x-directory',
|
||||
'text/directory',
|
||||
'httpd/unix-directory',
|
||||
'inode/directory',
|
||||
]);
|
||||
|
||||
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
|
||||
const t = node.type;
|
||||
if (!t) return false;
|
||||
return DIRECTORY_TYPES.has(t) || t.includes('directory');
|
||||
}
|
||||
|
||||
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
|
||||
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
|
||||
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
|
||||
// e.g. a file inside the `Documents` folder is stored with
|
||||
// `name = "Documents∕report.pdf"`. The display name shown to the user
|
||||
// strips the prefix.
|
||||
export const PATH_SEP = '∕';
|
||||
|
||||
// Path is a list of folder display names from root. [] is root,
|
||||
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
|
||||
// `sub` folder under Documents.
|
||||
export function pathToPrefix(path: readonly string[]): string {
|
||||
if (path.length === 0) return '';
|
||||
return path.join(PATH_SEP) + PATH_SEP;
|
||||
}
|
||||
|
||||
// True when `node` is a direct child (not a descendant) of `prefix`.
|
||||
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
|
||||
if (!node.name) return false;
|
||||
if (prefix === '') return !node.name.includes(PATH_SEP);
|
||||
if (!node.name.startsWith(prefix)) return false;
|
||||
const rest = node.name.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes(PATH_SEP);
|
||||
}
|
||||
|
||||
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
|
||||
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
|
||||
}
|
||||
|
||||
// Stalwart's FileNode/get does not support a parentId filter and some builds
|
||||
// omit `type` from the default property set. We fetch the whole tree and
|
||||
// filter client-side; properties are listed explicitly so `isDirectory()`
|
||||
// always has a value to look at.
|
||||
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
|
||||
|
||||
export async function getAllFileNodes(): 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']],
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
return (res.methodResponses[0][1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
@@ -27,13 +76,113 @@ export async function createFileNode(
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-file'];
|
||||
const created = res.methodResponses[0][1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = res.methodResponses[0][1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Create failed');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
displayName: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
// Stalwart's FileNode/set rejects directory creates without a blobId
|
||||
// ("Missing blob id"), so we upload an empty blob first and reference it.
|
||||
// Mirrors the webmail's createFileDirectory.
|
||||
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
// Rename a node and, if it's a folder, cascade the rename to every descendant
|
||||
// so the encoded path stays consistent. The webmail does this same dance.
|
||||
export async function renameFileNode(
|
||||
node: FileNode,
|
||||
newDisplayName: string,
|
||||
currentPath: readonly string[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const prefix = pathToPrefix(currentPath);
|
||||
const oldServerName = node.name;
|
||||
const newServerName = prefix + newDisplayName;
|
||||
if (oldServerName === newServerName) return;
|
||||
|
||||
await updateFileNode(node.id, { name: newServerName });
|
||||
|
||||
if (isDirectory(node)) {
|
||||
const oldFolderPrefix = oldServerName + PATH_SEP;
|
||||
const newFolderPrefix = newServerName + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.id === node.id) continue;
|
||||
if (n.name.startsWith(oldFolderPrefix)) {
|
||||
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
|
||||
// references, so we collect descendants by name-prefix and destroy them all
|
||||
// in one call. Without this, deleting a folder leaves its files orphaned and
|
||||
// still discoverable at the old encoded path.
|
||||
export async function deleteWithDescendants(
|
||||
targets: readonly FileNode[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const ids = new Set<string>();
|
||||
for (const node of targets) {
|
||||
ids.add(node.id);
|
||||
if (isDirectory(node)) {
|
||||
const folderPrefix = node.name + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteFileNodes(Array.from(ids));
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
displayName: string,
|
||||
mimeType: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({
|
||||
name: fullName,
|
||||
type: blob.type || mimeType || 'application/octet-stream',
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
});
|
||||
}
|
||||
|
||||
+259
-32
@@ -7,6 +7,12 @@ import type {
|
||||
} from './types';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
|
||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||
// in-flight requests don't race the expiry window.
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
@@ -21,6 +27,13 @@ export interface StoredCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
accessToken?: string;
|
||||
// OAuth-only — present when credentials came in via the webmail handoff
|
||||
// OAuth path rather than password auth. The token endpoint and client id
|
||||
// are needed for refresh; without them the access token would just expire.
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenEndpoint?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export class JMAPClient {
|
||||
@@ -53,7 +66,10 @@ export class JMAPClient {
|
||||
|
||||
// ── Authentication ────────────────────────────────────
|
||||
|
||||
private get authHeader(): string {
|
||||
// Public so blob upload/download helpers (which can't go through the JMAP
|
||||
// request body) can fetch the same Authorization header the rest of the
|
||||
// client uses. The value is recomputed each access and never cached.
|
||||
get authHeader(): string {
|
||||
if (!this.credentials) throw new Error('No credentials');
|
||||
if (this.credentials.accessToken) {
|
||||
return `Bearer ${this.credentials.accessToken}`;
|
||||
@@ -89,6 +105,113 @@ export class JMAPClient {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||
// Stalwart and handed us a complete token bundle. We persist the bundle
|
||||
// so subsequent launches can refresh without prompting the user again.
|
||||
async connectWithOAuth(
|
||||
serverUrl: string,
|
||||
tokens: OAuthTokens,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = {
|
||||
serverUrl: baseUrl,
|
||||
username: '',
|
||||
password: '',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
|
||||
// The JMAP session document carries the authenticated user's identifier
|
||||
// — use it as the username so per-account storage keys are stable across
|
||||
// restarts and OAuth re-logins.
|
||||
const username = this.session.username || tokens.accessToken.slice(0, 8);
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
}
|
||||
|
||||
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
|
||||
if (!this.credentials) return;
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: next.accessToken,
|
||||
// Some IdPs rotate refresh tokens; others reuse. Fall back to the
|
||||
// existing one when the response omits it.
|
||||
refreshToken: next.refreshToken ?? this.credentials.refreshToken,
|
||||
expiresAt: next.expiresAt,
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
const accountId = generateAccountId(
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
}
|
||||
|
||||
private currentOAuthTokens(): OAuthTokens | null {
|
||||
if (
|
||||
!this.credentials?.accessToken ||
|
||||
!this.credentials.refreshToken ||
|
||||
!this.credentials.tokenEndpoint ||
|
||||
!this.credentials.clientId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken: this.credentials.accessToken,
|
||||
refreshToken: this.credentials.refreshToken,
|
||||
expiresAt: this.credentials.expiresAt,
|
||||
tokenEndpoint: this.credentials.tokenEndpoint,
|
||||
clientId: this.credentials.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
// Proactive refresh: when the access token is about to expire, swap it for
|
||||
// a fresh one. Quiet no-op for password credentials.
|
||||
private async ensureFreshToken(): Promise<void> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return;
|
||||
if (tokens.expiresAt == null) return;
|
||||
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
} catch {
|
||||
// Surface as AuthenticationError on the next 401; refresh may be
|
||||
// temporarily failing (network) and the reactive retry path catches it.
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive refresh after a 401. Returns true if a fresh token was obtained
|
||||
// so the caller can retry the original request.
|
||||
private async forceRefreshToken(): Promise<boolean> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return false;
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
@@ -114,18 +237,38 @@ export class JMAPClient {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return false;
|
||||
|
||||
let creds: StoredCredentials;
|
||||
try {
|
||||
creds = JSON.parse(stored);
|
||||
} catch {
|
||||
// Corrupt entry — credentials can't be used. Caller should evict.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Errors past this point propagate so callers can distinguish
|
||||
// unrecoverable (AuthenticationError) from transient (NetworkError) and
|
||||
// avoid clearing credentials when the server is just unreachable.
|
||||
this.credentials = creds;
|
||||
try {
|
||||
const creds: StoredCredentials = JSON.parse(stored);
|
||||
this.credentials = creds;
|
||||
this.session = this.rewriteSessionUrls(
|
||||
await this.fetchSession(creds.serverUrl),
|
||||
creds.serverUrl,
|
||||
);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Don't leave a half-populated client behind; the caller needs to know
|
||||
// the session is unavailable. Credentials stay in memory so a retry
|
||||
// after the network comes back doesn't need a re-login.
|
||||
this.session = null;
|
||||
this._accountId = null;
|
||||
if (err instanceof AuthenticationError) throw err;
|
||||
// Anything else is treated as transport-level. Wrapping rather than
|
||||
// rethrowing the raw fetch error gives callers a single check.
|
||||
throw new NetworkError(
|
||||
err instanceof Error ? err.message : 'Server unreachable',
|
||||
);
|
||||
}
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
}
|
||||
|
||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||
@@ -161,17 +304,18 @@ export class JMAPClient {
|
||||
// JMAP servers often self-report localhost/container-internal hostnames in
|
||||
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
|
||||
// the Android emulator can't resolve the host's "localhost").
|
||||
//
|
||||
// Splits via plain string indexing rather than `new URL()` because RN's
|
||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||
// before the caller has a chance to substitute values into them.
|
||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||
const serverOrigin = this.extractOrigin(serverUrl);
|
||||
const rewrite = (url: string | undefined): string | undefined => {
|
||||
if (!url) return url;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const server = new URL(serverUrl);
|
||||
if (parsed.origin === server.origin) return url;
|
||||
return server.origin + parsed.pathname + parsed.search + parsed.hash;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
const origin = this.extractOrigin(url);
|
||||
if (!origin || !serverOrigin || origin === serverOrigin) return url;
|
||||
return serverOrigin + url.slice(origin.length);
|
||||
};
|
||||
return {
|
||||
...session,
|
||||
@@ -182,6 +326,11 @@ export class JMAPClient {
|
||||
};
|
||||
}
|
||||
|
||||
private extractOrigin(url: string): string | null {
|
||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Reset in-memory state without touching persisted credentials (used on
|
||||
// account switch so the active account's stored creds remain available).
|
||||
reset(): void {
|
||||
@@ -205,12 +354,18 @@ export class JMAPClient {
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () =>
|
||||
secureFetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Invalid credentials');
|
||||
@@ -250,14 +405,21 @@ export class JMAPClient {
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const response = await fetch(this.session.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () =>
|
||||
secureFetch(this.session!.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Session expired');
|
||||
@@ -289,6 +451,59 @@ export class JMAPClient {
|
||||
return core?.maxObjectsInGet || 500;
|
||||
}
|
||||
|
||||
// ── Scheduled send (FUTURERELEASE) ────────────────────
|
||||
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
|
||||
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
|
||||
// lets us defer delivery via the SMTP HOLDFOR parameter. Mirrors the webmail
|
||||
// implementation so behaviour stays in sync across platforms.
|
||||
|
||||
private get submissionCapability():
|
||||
| { maxDelayedSend?: number; submissionExtensions?: unknown }
|
||||
| undefined {
|
||||
return this.session?.capabilities?.[CAPABILITIES.SUBMISSION] as
|
||||
| { maxDelayedSend?: number; submissionExtensions?: unknown }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
getMaxDelayedSend(): number {
|
||||
const max = this.submissionCapability?.maxDelayedSend;
|
||||
return typeof max === 'number' ? max : 0;
|
||||
}
|
||||
|
||||
hasDelayedSend(): boolean {
|
||||
const cap = this.submissionCapability;
|
||||
if (!cap) return false;
|
||||
const ext = cap.submissionExtensions;
|
||||
// submissionExtensions is a map of extension name → params. FUTURERELEASE
|
||||
// (RFC 4865) is the SMTP extension that backs deferred delivery.
|
||||
const hasFutureRelease =
|
||||
!!ext &&
|
||||
typeof ext === 'object' &&
|
||||
Object.keys(ext as Record<string, unknown>).some(
|
||||
(k) => k.toUpperCase() === 'FUTURERELEASE',
|
||||
);
|
||||
return hasFutureRelease && this.getMaxDelayedSend() > 0;
|
||||
}
|
||||
|
||||
// ── Stored credentials (per-account) ──────────────────
|
||||
// Exposed so the unified-inbox aggregator can read another account's
|
||||
// credentials without disturbing this client's live session, and persist a
|
||||
// refreshed OAuth token back to secure storage.
|
||||
|
||||
async getStoredCredentials(accountId: string): Promise<StoredCredentials | null> {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return JSON.parse(stored) as StoredCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
|
||||
}
|
||||
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
|
||||
// Expand the RFC 6570 level-1 template the JMAP server advertises.
|
||||
@@ -305,9 +520,12 @@ export class JMAPClient {
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: this.authHeader },
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
if (response.status === 401) throw new AuthenticationError('Session expired');
|
||||
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
@@ -323,6 +541,15 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Server unreachable / transient transport failure. Callers should keep
|
||||
// stored credentials and surface an offline state rather than logging out.
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
|
||||
+66
-18
@@ -24,34 +24,60 @@ export async function createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
types: string[];
|
||||
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
|
||||
// want and accept whatever Stalwart returns.
|
||||
expires?: string;
|
||||
}): Promise<string> {
|
||||
const created: Record<string, unknown> = {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
};
|
||||
if (params.expires) created.expires = params.expires;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{
|
||||
create: {
|
||||
new: {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ create: { new: created } },
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
const created = body?.created?.new as { id?: string } | undefined;
|
||||
if (!created?.id) {
|
||||
const result = body?.created?.new as { id?: string } | undefined;
|
||||
if (!result?.id) {
|
||||
const notCreated = body?.notCreated?.new;
|
||||
throw new Error(
|
||||
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
|
||||
);
|
||||
}
|
||||
return created.id;
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the subscription's expiry forward (RFC 8620 §7.2.1). Returns false if
|
||||
* the server rejected the update (e.g. the subscription no longer exists),
|
||||
* which the caller treats as a signal to recreate.
|
||||
*/
|
||||
export async function updatePushSubscription(
|
||||
id: string,
|
||||
patch: { expires?: string; types?: string[] },
|
||||
): Promise<boolean> {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{ update: { [id]: patch } },
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
if (body?.notUpdated?.[id]) return false;
|
||||
return body?.updated?.[id] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +134,7 @@ export function connectEventSource(
|
||||
: require('react-native-sse').default;
|
||||
|
||||
const es = new EventSourceImpl(url, {
|
||||
headers: { Authorization: (jmapClient as any).authHeader },
|
||||
headers: { Authorization: jmapClient.authHeader },
|
||||
});
|
||||
|
||||
const handler = (event: any) => {
|
||||
@@ -175,13 +201,35 @@ export function startPolling(
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
export interface StartPushOptions {
|
||||
onStateChange: StateChangeHandler;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start real-time updates, preferring SSE with polling fallback.
|
||||
* Start real-time updates, preferring SSE with polling fallback. The `_client`
|
||||
* argument is unused today (the underlying event source uses the singleton
|
||||
* `jmapClient`) but is part of the call signature so the host App can pass
|
||||
* the active client reference and trigger React re-runs on account switch.
|
||||
*/
|
||||
export function startPushUpdates(
|
||||
onStateChange: StateChangeHandler,
|
||||
_client: unknown,
|
||||
opts: StartPushOptions,
|
||||
): () => void {
|
||||
const cleanup = connectEventSource(onStateChange);
|
||||
if (cleanup) return cleanup;
|
||||
return startPolling(onStateChange);
|
||||
const onError = opts.onError;
|
||||
const safeOnStateChange: StateChangeHandler = (change) => {
|
||||
try {
|
||||
opts.onStateChange(change);
|
||||
} catch (err) {
|
||||
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
};
|
||||
try {
|
||||
const cleanup = connectEventSource(safeOnStateChange);
|
||||
if (cleanup) return cleanup;
|
||||
return startPolling(safeOnStateChange);
|
||||
} catch (err) {
|
||||
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
||||
return () => undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+98
-4
@@ -9,6 +9,10 @@ export interface JMAPSession {
|
||||
accounts: Record<string, JMAPAccountInfo>;
|
||||
capabilities: Record<string, unknown>;
|
||||
state: string;
|
||||
// RFC 8620 §2 — authenticated user identifier. Optional because not all
|
||||
// servers populate it; OAuth login uses it as the account label when
|
||||
// there's no password username to fall back on.
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface JMAPAccountInfo {
|
||||
@@ -191,6 +195,8 @@ export interface ContactAddress {
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
fullAddress?: string;
|
||||
coordinates?: string;
|
||||
timeZone?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
pref?: number;
|
||||
@@ -276,6 +282,30 @@ export interface ContactLink {
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactCryptoKey {
|
||||
uri: string;
|
||||
mediaType?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactDirectory {
|
||||
uri: string;
|
||||
kind?: 'directory' | 'entry' | string;
|
||||
mediaType?: string;
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactPronouns {
|
||||
pronouns: string;
|
||||
pref?: number;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactSpeakToAs {
|
||||
grammaticalGender?: string;
|
||||
pronouns?: Record<string, ContactPronouns>;
|
||||
}
|
||||
|
||||
export interface ContactCard {
|
||||
id: string;
|
||||
originalId?: string;
|
||||
@@ -298,8 +328,16 @@ export interface ContactCard {
|
||||
media?: Record<string, ContactMedia>;
|
||||
relatedTo?: Record<string, ContactRelation>;
|
||||
links?: Record<string, ContactLink>;
|
||||
cryptoKeys?: Record<string, ContactCryptoKey>;
|
||||
directories?: Record<string, ContactDirectory>;
|
||||
keywords?: Record<string, boolean>;
|
||||
members?: Record<string, boolean>;
|
||||
speakToAs?: ContactSpeakToAs;
|
||||
calendarUri?: string;
|
||||
schedulingUri?: string;
|
||||
freeBusyUri?: string;
|
||||
source?: string;
|
||||
prodId?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
@@ -328,13 +366,39 @@ export interface AddressBookWithCount extends AddressBook {
|
||||
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
|
||||
|
||||
export interface Participant {
|
||||
'@type'?: 'Participant';
|
||||
name?: string;
|
||||
email?: string;
|
||||
sendTo?: Record<string, string>;
|
||||
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
|
||||
calendarAddress?: string;
|
||||
kind?: string;
|
||||
roles?: Record<string, boolean>;
|
||||
participationStatus?: string;
|
||||
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
|
||||
participationComment?: string;
|
||||
scheduleStatus?: string[];
|
||||
expectReply?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// JSCalendar Location (RFC 8984 §4.2.5) — a physical place.
|
||||
export interface EventLocation {
|
||||
'@type'?: 'Location';
|
||||
name?: string;
|
||||
description?: string;
|
||||
locationTypes?: Record<string, boolean>;
|
||||
relativeTo?: string;
|
||||
timeZone?: string;
|
||||
coordinates?: string;
|
||||
}
|
||||
|
||||
// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place.
|
||||
export interface VirtualLocation {
|
||||
'@type'?: 'VirtualLocation';
|
||||
name?: string;
|
||||
description?: string;
|
||||
uri: string;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RecurrenceRule {
|
||||
@@ -375,27 +439,56 @@ export interface CalendarEvent {
|
||||
status?: string;
|
||||
freeBusyStatus?: string;
|
||||
participants?: Record<string, Participant>;
|
||||
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
|
||||
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
|
||||
replyTo?: Record<string, string>;
|
||||
organizerCalendarAddress?: string;
|
||||
sequence?: number;
|
||||
recurrenceRules?: RecurrenceRule[];
|
||||
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
|
||||
excludedRecurrenceRules?: RecurrenceRule[];
|
||||
recurrenceId?: string;
|
||||
originalId?: string;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, Alert>;
|
||||
locations?: Record<string, EventLocation>;
|
||||
virtualLocations?: Record<string, VirtualLocation>;
|
||||
links?: Record<string, { href: string; rel?: string }>;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
progress?: string;
|
||||
// Task-only (RFC 8984 JSTask) fields.
|
||||
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
|
||||
due?: string;
|
||||
priority?: number;
|
||||
percentComplete?: number;
|
||||
color?: string;
|
||||
// Client-only: account the event/occurrence belongs to (for shared/virtual).
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
mayReadFreeBusy?: boolean;
|
||||
mayReadItems?: boolean;
|
||||
mayWriteAll?: boolean;
|
||||
mayWriteOwn?: boolean;
|
||||
mayUpdatePrivate?: boolean;
|
||||
mayRSVP?: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete?: boolean;
|
||||
// Legacy short flag still read by the sidebar drawer.
|
||||
mayWrite?: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color?: string;
|
||||
isVisible?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
myRights?: Record<string, boolean>;
|
||||
myRights?: CalendarRights;
|
||||
}
|
||||
|
||||
// ─── Files (FileNode) ───────────────────────────────────
|
||||
@@ -405,7 +498,7 @@ export interface FileNode {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type: string;
|
||||
blobId?: string;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
@@ -441,4 +534,5 @@ export const CAPABILITIES = {
|
||||
CALENDARS: 'urn:ietf:params:jmap:calendars',
|
||||
SIEVE: 'urn:ietf:params:jmap:sieve',
|
||||
QUOTA: 'urn:ietf:params:jmap:quota',
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types';
|
||||
import { jmapClient, type StoredCredentials } from './jmap-client';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
|
||||
// Aggregated inbox across every logged-in account. Because the JMAP client is
|
||||
// a single-account singleton (one live session at a time), we fetch each
|
||||
// account's inbox "detached" here — reading its stored credentials directly and
|
||||
// issuing requests against its own API URL — so the active account's live
|
||||
// session is never disturbed.
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment',
|
||||
];
|
||||
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
|
||||
export interface UnifiedEmail extends Email {
|
||||
/** Registry account id this message belongs to (not the JMAP account id). */
|
||||
sourceAccountId: string;
|
||||
}
|
||||
|
||||
export interface UnifiedInboxResult {
|
||||
emails: UnifiedEmail[];
|
||||
/** accountId → error message for accounts that could not be fetched. */
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
function authHeaderFor(creds: StoredCredentials): string {
|
||||
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
|
||||
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
|
||||
}
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at
|
||||
// the origin we actually connected to (servers often self-report unreachable
|
||||
// container-internal hosts).
|
||||
function rewriteApiUrl(session: JMAPSession, serverUrl: string): string {
|
||||
const serverOrigin = originOf(serverUrl);
|
||||
const apiOrigin = originOf(session.apiUrl);
|
||||
if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl;
|
||||
return serverOrigin + session.apiUrl.slice(apiOrigin.length);
|
||||
}
|
||||
|
||||
function resolveJmapAccountId(session: JMAPSession): string | null {
|
||||
return (
|
||||
session.primaryAccounts?.[CAPABILITIES.MAIL] ||
|
||||
session.primaryAccounts?.[CAPABILITIES.CORE] ||
|
||||
Object.keys(session.accounts ?? {})[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh an about-to-expire OAuth token and persist it so the next fetch (and
|
||||
// a later account switch) reuse the fresh one. Returns possibly-updated creds.
|
||||
async function ensureFreshCredentials(
|
||||
accountId: string,
|
||||
creds: StoredCredentials,
|
||||
): Promise<StoredCredentials> {
|
||||
if (
|
||||
!creds.accessToken ||
|
||||
!creds.refreshToken ||
|
||||
!creds.tokenEndpoint ||
|
||||
!creds.clientId ||
|
||||
creds.expiresAt == null
|
||||
) {
|
||||
return creds;
|
||||
}
|
||||
if (creds.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return creds;
|
||||
try {
|
||||
const tokens: OAuthTokens = {
|
||||
accessToken: creds.accessToken,
|
||||
refreshToken: creds.refreshToken,
|
||||
expiresAt: creds.expiresAt,
|
||||
tokenEndpoint: creds.tokenEndpoint,
|
||||
clientId: creds.clientId,
|
||||
};
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
const updated: StoredCredentials = {
|
||||
...creds,
|
||||
accessToken: next.accessToken,
|
||||
refreshToken: next.refreshToken ?? creds.refreshToken,
|
||||
expiresAt: next.expiresAt,
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
return updated;
|
||||
} catch {
|
||||
// Fall back to the existing (possibly expired) token; the request will
|
||||
// surface a 401 which we report as an error for this account.
|
||||
return creds;
|
||||
}
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JMAPMethodCall[],
|
||||
using: string[],
|
||||
): Promise<Array<[string, Record<string, any>, string]>> {
|
||||
const response = await secureFetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
if (response.status === 401) throw new Error('Session expired');
|
||||
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
|
||||
const body = await response.json();
|
||||
return body.methodResponses ?? [];
|
||||
}
|
||||
|
||||
async function fetchInboxForAccount(
|
||||
accountId: string,
|
||||
limit: number,
|
||||
): Promise<UnifiedEmail[]> {
|
||||
let creds = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!creds) throw new Error('No stored credentials');
|
||||
creds = await ensureFreshCredentials(accountId, creds);
|
||||
|
||||
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
|
||||
const authHeader = authHeaderFor(creds);
|
||||
|
||||
// Session discovery.
|
||||
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader, Accept: 'application/json' },
|
||||
});
|
||||
if (sessionRes.status === 401) throw new Error('Session expired');
|
||||
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
|
||||
const session = (await sessionRes.json()) as JMAPSession;
|
||||
const apiUrl = rewriteApiUrl(session, baseUrl);
|
||||
const jmapAccountId = resolveJmapAccountId(session);
|
||||
if (!jmapAccountId) throw new Error('No mail account in session');
|
||||
|
||||
// Find the inbox mailbox.
|
||||
const mailboxResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? [];
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
|
||||
if (!inbox) return [];
|
||||
|
||||
// Query + fetch the most recent messages.
|
||||
const queryResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/query', {
|
||||
accountId: jmapAccountId,
|
||||
filter: { inMailbox: inbox.id },
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const getResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const list = (getResponses[0]?.[1]?.list as Email[]) ?? [];
|
||||
return list.map((e) => ({ ...e, sourceAccountId: accountId }));
|
||||
}
|
||||
|
||||
export async function fetchUnifiedInbox(
|
||||
accountIds: string[],
|
||||
perAccountLimit = 25,
|
||||
): Promise<UnifiedInboxResult> {
|
||||
const errors: Record<string, string> = {};
|
||||
const settled = await Promise.all(
|
||||
accountIds.map(async (id) => {
|
||||
try {
|
||||
return await fetchInboxForAccount(id, perAccountLimit);
|
||||
} catch (err) {
|
||||
errors[id] = err instanceof Error ? err.message : 'Failed to load';
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const emails = settled
|
||||
.flat()
|
||||
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
return { emails, errors };
|
||||
}
|
||||
@@ -13,6 +13,45 @@ export interface LatestRelease {
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
apkAsset: ReleaseAsset | null;
|
||||
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
|
||||
// asset or from the release body. Null when the release didn't publish one;
|
||||
// the installer falls back to relying solely on Android's package signing
|
||||
// check in that case (still strong, but less defense-in-depth).
|
||||
apkSha256: string | null;
|
||||
}
|
||||
|
||||
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
|
||||
|
||||
function extractSha256(body: string, apkName: string | undefined): string | null {
|
||||
if (!body) return null;
|
||||
// Prefer a line that explicitly references the APK so we don't pick up the
|
||||
// hash of an unrelated asset.
|
||||
const lines = body.split(/\r?\n/);
|
||||
if (apkName) {
|
||||
for (const line of lines) {
|
||||
if (line.includes(apkName)) {
|
||||
const m = SHA256_RE.exec(line);
|
||||
if (m) return m[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: a single SHA256 anywhere in the body — common when the release
|
||||
// notes just list one checksum.
|
||||
const m = SHA256_RE.exec(body);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
|
||||
if (!asset) return null;
|
||||
try {
|
||||
const res = await fetch(asset.browser_download_url);
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
const m = SHA256_RE.exec(text);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
|
||||
@@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
|
||||
const sha256Asset = apkAsset
|
||||
? json.assets.find(
|
||||
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
|
||||
)
|
||||
: undefined;
|
||||
const apkSha256 =
|
||||
(await fetchCompanionSha256(sha256Asset)) ??
|
||||
extractSha256(json.body || '', apkAsset?.name);
|
||||
return {
|
||||
tag: json.tag_name,
|
||||
name: json.name || json.tag_name,
|
||||
@@ -39,5 +86,6 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
||||
publishedAt: json.published_at,
|
||||
body: json.body || '',
|
||||
apkAsset,
|
||||
apkSha256,
|
||||
};
|
||||
}
|
||||
|
||||
+28
-45
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface BadgeProps {
|
||||
children: string;
|
||||
@@ -8,57 +9,39 @@ interface BadgeProps {
|
||||
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) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.base, variantStyles[variant], style]}>
|
||||
<Text style={[styles.text, textVariantStyles[variant]]}>
|
||||
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
|
||||
{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',
|
||||
},
|
||||
});
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
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,
|
||||
},
|
||||
};
|
||||
bg_default: { backgroundColor: c.surface },
|
||||
bg_primary: { backgroundColor: c.primaryBg },
|
||||
bg_destructive: { backgroundColor: c.errorBg },
|
||||
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
|
||||
bg_muted: { backgroundColor: c.muted },
|
||||
|
||||
text_default: { color: c.text },
|
||||
text_primary: { color: c.primary },
|
||||
text_destructive: { color: c.error },
|
||||
text_outline: { color: c.text },
|
||||
text_muted: { color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
+38
-106
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Pressable, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator } from 'react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||
@@ -8,7 +9,7 @@ type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||
interface ButtonProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
@@ -26,15 +27,17 @@ export default function Button({
|
||||
icon,
|
||||
style,
|
||||
}: ButtonProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.base,
|
||||
sizeStyles[size],
|
||||
variantStyles[variant],
|
||||
pressed && !isDisabled && pressedStyles[variant],
|
||||
styles[`size_${size}` as const],
|
||||
styles[`bg_${variant}` as const],
|
||||
pressed && !isDisabled && styles[`pressed_${variant}` as const],
|
||||
isDisabled && styles.disabled,
|
||||
style,
|
||||
]}
|
||||
@@ -44,13 +47,13 @@ export default function Button({
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={variant === 'default' ? colors.primaryForeground : colors.primary}
|
||||
color={variant === 'default' ? c.primaryForeground : c.primary}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{icon}
|
||||
{typeof children === 'string' ? (
|
||||
<Text style={[styles.text, textVariantStyles[variant], isDisabled && styles.textDisabled]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -62,105 +65,34 @@ export default function Button({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md, gap: spacing.sm,
|
||||
},
|
||||
text: { ...typography.bodyMedium },
|
||||
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,
|
||||
},
|
||||
};
|
||||
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
|
||||
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
|
||||
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
|
||||
size_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,
|
||||
},
|
||||
};
|
||||
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
|
||||
bg_ghost: { backgroundColor: 'transparent' },
|
||||
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
|
||||
bg_destructive: { backgroundColor: c.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,
|
||||
},
|
||||
};
|
||||
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
|
||||
pressed_ghost: { backgroundColor: c.accent },
|
||||
pressed_outline: { backgroundColor: c.accent },
|
||||
pressed_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,
|
||||
},
|
||||
};
|
||||
text_default: { color: c.primaryForeground },
|
||||
text_ghost: { color: c.text },
|
||||
text_outline: { color: c.text },
|
||||
text_destructive: { color: c.errorForeground },
|
||||
});
|
||||
}
|
||||
|
||||
+19
-10
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
@@ -13,6 +14,8 @@ interface CardProps {
|
||||
* - card-foreground for text
|
||||
*/
|
||||
export function Card({ children, style }: CardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return <View style={[styles.card, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
@@ -27,6 +30,8 @@ interface SectionHeaderProps {
|
||||
* - text-sm text-muted-foreground for description
|
||||
*/
|
||||
export function SectionHeader({ title, description }: SectionHeaderProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
@@ -51,6 +56,8 @@ interface SettingItemRowProps {
|
||||
* - description: text-xs text-muted-foreground mt-1
|
||||
*/
|
||||
export function SettingItemRow({ label, description, right, icon, iconBg, bottomBorder = true }: SettingItemRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.settingRow, bottomBorder && styles.settingRowBorder]}>
|
||||
{icon && (
|
||||
@@ -67,12 +74,13 @@ export function SettingItemRow({ label, description, right, icon, iconBg, bottom
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg, // rounded-lg
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionHeader: {
|
||||
@@ -81,11 +89,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.h3, // text-lg font-medium
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
sectionDescription: {
|
||||
...typography.body,
|
||||
color: colors.mutedForeground, // text-muted-foreground
|
||||
color: c.mutedForeground, // text-muted-foreground
|
||||
marginTop: 4,
|
||||
},
|
||||
settingRow: {
|
||||
@@ -97,7 +105,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
settingRowBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border, // border-b border-border
|
||||
borderBottomColor: c.border, // border-b border-border
|
||||
},
|
||||
settingIcon: {
|
||||
width: 32,
|
||||
@@ -112,14 +120,15 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
settingLabel: {
|
||||
...typography.bodyMedium, // text-sm font-medium text-foreground
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
settingDescription: {
|
||||
...typography.caption, // text-xs text-muted-foreground
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
marginTop: 2,
|
||||
},
|
||||
settingRight: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ 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';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface DialogProps {
|
||||
visible: boolean;
|
||||
@@ -34,6 +35,8 @@ export default function Dialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DialogProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
@@ -41,7 +44,7 @@ export default function Dialog({
|
||||
<View style={styles.content}>
|
||||
{variant === 'destructive' && (
|
||||
<View style={styles.iconBadge}>
|
||||
<AlertTriangle size={20} color={colors.error} />
|
||||
<AlertTriangle size={20} color={c.error} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
@@ -61,7 +64,8 @@ export default function Dialog({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
@@ -72,9 +76,9 @@ const styles = StyleSheet.create({
|
||||
dialog: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.lg,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
@@ -89,18 +93,18 @@ const styles = StyleSheet.create({
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.errorBg,
|
||||
backgroundColor: c.errorBg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
title: {
|
||||
...typography.h3, // text-lg font-semibold
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
message: {
|
||||
...typography.body, // text-sm
|
||||
color: colors.mutedForeground, // text-muted-foreground
|
||||
color: c.mutedForeground, // text-muted-foreground
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
footer: {
|
||||
@@ -110,4 +114,5 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.xxl, // px-6
|
||||
paddingBottom: spacing.xxl, // pb-6
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { Email } from '../api/types';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { colors, spacing, typography } from '../theme/tokens';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
|
||||
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
|
||||
// skipped so we don't freeze the JS thread base64-encoding megabytes.
|
||||
@@ -129,9 +131,44 @@ const HEIGHT_REPORTER = `
|
||||
`;
|
||||
|
||||
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
||||
const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook);
|
||||
const resolvedTheme = useResolvedTheme();
|
||||
|
||||
// Passively load the dedicated "Trusted Senders" address book once so its
|
||||
// entries (synced across devices) feed the external-content trust check. We
|
||||
// do not create the book here — it is created lazily when a sender is trusted.
|
||||
React.useEffect(() => {
|
||||
if (!trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
// Index the address book once per render so the trusted-sender check below
|
||||
// is O(1) per email. Only used when the matching setting is on.
|
||||
const addressBookEmails = React.useMemo(() => {
|
||||
if (!trustedSendersAddressBook) return null;
|
||||
const out = new Set<string>();
|
||||
for (const c of contacts) {
|
||||
if (!c.emails) continue;
|
||||
for (const e of Object.values(c.emails)) {
|
||||
if (e?.address) out.add(e.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [contacts, trustedSendersAddressBook]);
|
||||
// Most marketing email is authored against a white background, so dark-mode
|
||||
// inversion can wreck logos/banners. With this flag the user opts to render
|
||||
// emails on a light surface even while the rest of the app is dark.
|
||||
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark';
|
||||
|
||||
const html = React.useMemo(() => extractHtmlBody(email), [email]);
|
||||
const text = React.useMemo(() => extractTextBody(email), [email]);
|
||||
@@ -142,7 +179,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
[html, text],
|
||||
);
|
||||
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
|
||||
const trusted = senderEmail ? isSenderTrusted(senderEmail) : false;
|
||||
const trusted = senderEmail
|
||||
? isSenderTrusted(senderEmail)
|
||||
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|
||||
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
|
||||
: false;
|
||||
|
||||
// One-time override: user tapped "Load images" for this email only.
|
||||
const [allowOnce, setAllowOnce] = React.useState(false);
|
||||
@@ -215,17 +256,22 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
html: wrapEmailHtml(rawHtml, {
|
||||
blockRemoteImages: shouldBlock,
|
||||
cidMap,
|
||||
isDark: renderAsDark,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const fallbackText = text ?? email.preview ?? '';
|
||||
if (!fallbackText) {
|
||||
return {
|
||||
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>'),
|
||||
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', {
|
||||
isDark: renderAsDark,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText)) };
|
||||
}, [rawHtml, text, email.preview, shouldBlock, cidMap]);
|
||||
return {
|
||||
html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText), { isDark: renderAsDark }),
|
||||
};
|
||||
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
|
||||
|
||||
const onMessage = (e: WebViewMessageEvent) => {
|
||||
const parsed = parseInt(e.nativeEvent.data, 10);
|
||||
@@ -236,7 +282,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect, and persist to the
|
||||
// dedicated "Trusted Senders" address book so the trust syncs across
|
||||
// devices (matches the webmail behavior).
|
||||
addTrustedSender(senderEmail);
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
setAllowOnce(true);
|
||||
}
|
||||
};
|
||||
@@ -247,13 +297,13 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
<View style={styles.banner}>
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
||||
<ImageIcon size={14} color={colors.textSecondary} />
|
||||
<ImageIcon size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>Load external content</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{senderEmail ? (
|
||||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||||
<ShieldCheck size={14} color={colors.textSecondary} />
|
||||
<ShieldCheck size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>Trust sender</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -263,7 +313,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
<View style={[styles.webContainer, { height }]}>
|
||||
<WebView
|
||||
ref={webviewRef}
|
||||
originWhitelist={['about:*']}
|
||||
originWhitelist={['about:blank']}
|
||||
source={source}
|
||||
javaScriptEnabled
|
||||
injectedJavaScript={HEIGHT_REPORTER}
|
||||
@@ -308,7 +358,8 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrapper: { width: '100%' },
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
@@ -317,9 +368,9 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surfaceHover,
|
||||
backgroundColor: c.surfaceHover,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
bannerButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -329,7 +380,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bannerButtonText: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
webContainer: {
|
||||
width: '100%',
|
||||
@@ -343,4 +394,5 @@ const styles = StyleSheet.create({
|
||||
webviewContainer: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+16
-11
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
label?: string;
|
||||
@@ -28,6 +29,8 @@ export default function Input({
|
||||
style,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
return (
|
||||
@@ -43,7 +46,7 @@ export default function Input({
|
||||
<RNTextInput
|
||||
{...props}
|
||||
style={[styles.input, style]}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
|
||||
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
|
||||
/>
|
||||
@@ -54,10 +57,11 @@ export default function Input({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
label: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
inputWrapper: {
|
||||
@@ -65,17 +69,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
height: componentSizes.inputHeight, // h-10 = 40px
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border, // border-input
|
||||
borderColor: c.border, // border-input
|
||||
borderRadius: radius.sm, // rounded-md = 6px
|
||||
backgroundColor: colors.background, // bg-background
|
||||
backgroundColor: c.background, // bg-background
|
||||
paddingHorizontal: spacing.md, // px-3 = 12px
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: colors.borderFocus, // border-ring (blue-500)
|
||||
borderColor: c.borderFocus, // border-ring (blue-500)
|
||||
borderWidth: 2,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: colors.error,
|
||||
borderColor: c.error,
|
||||
},
|
||||
inputDisabled: {
|
||||
opacity: 0.5,
|
||||
@@ -83,7 +87,7 @@ const styles = StyleSheet.create({
|
||||
input: {
|
||||
flex: 1,
|
||||
...typography.body, // text-sm = 14px
|
||||
color: colors.text, // text-foreground
|
||||
color: c.text, // text-foreground
|
||||
paddingVertical: 0,
|
||||
},
|
||||
iconLeft: {
|
||||
@@ -94,7 +98,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
errorText: {
|
||||
...typography.caption,
|
||||
color: colors.error,
|
||||
color: c.error,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
|
||||
X, Check, type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import type { Mailbox } from '../api/types';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
||||
const lower = name.toLowerCase();
|
||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
||||
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
|
||||
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
||||
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
|
||||
if (role === 'archive' || lower.includes('archive')) return Archive;
|
||||
return Folder;
|
||||
}
|
||||
|
||||
interface MoveSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
mailboxes: Mailbox[];
|
||||
/** Folder the email is currently in - shown with a check, not selectable. */
|
||||
currentMailboxId: string | null;
|
||||
onPick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MoveSheet({
|
||||
visible, onClose, mailboxes, currentMailboxId, onPick,
|
||||
}: MoveSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const visibleNodes = React.useMemo(() => {
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const expanded = new Set<string>();
|
||||
const collect = (nodes: MailboxNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.children.length > 0) {
|
||||
expanded.add(n.id);
|
||||
collect(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(tree);
|
||||
return flattenVisible(tree, expanded);
|
||||
}, [mailboxes]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Move to folder</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
|
||||
return (
|
||||
<Pressable
|
||||
key={node.id}
|
||||
onPress={canTarget ? () => onPick(node.id) : undefined}
|
||||
disabled={!canTarget}
|
||||
style={({ pressed }) => [
|
||||
styles.moveRow,
|
||||
pressed && canTarget && styles.moveRowPressed,
|
||||
{ paddingLeft: spacing.lg + node.depth * 16 },
|
||||
]}
|
||||
>
|
||||
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
|
||||
<Text
|
||||
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
sheetOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetOverlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetHandleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
sheetHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: c.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
moveRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
moveRowPressed: { backgroundColor: c.surfaceHover },
|
||||
moveRowLabel: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
flex: 1,
|
||||
},
|
||||
moveRowLabelDisabled: {
|
||||
color: c.textMuted,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { CloudOff } from 'lucide-react-native';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface OfflineBannerProps {
|
||||
/** Optional extra notice (e.g. "Showing cached mail"). */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function OfflineBanner({ hint }: OfflineBannerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const online = useNetworkStore((s) => s.online);
|
||||
if (online) return null;
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<CloudOff size={14} color={c.primaryForeground} />
|
||||
<Text style={styles.text} numberOfLines={1}>
|
||||
You are offline{hint ? ` — ${hint}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: '#a16207',
|
||||
},
|
||||
text: {
|
||||
...typography.caption,
|
||||
color: c.primaryForeground,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Live banner that mirrors the UpdateBanner's footprint, surfaced while the
|
||||
// offline mail sync is running. Hidden once the sync settles to idle/done.
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CloudDownload, X } from 'lucide-react-native';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { formatBytes } from '../lib/offline-sync';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
|
||||
const resetSync = useOfflineCacheStore((s) => s.resetSync);
|
||||
// When UpdateBanner is stacked above us it already absorbs the status-bar
|
||||
// inset, so we only add it when we're the topmost banner.
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
const updateBannerVisible =
|
||||
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
|
||||
const topInset = updateBannerVisible ? 0 : insets.top;
|
||||
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
|
||||
// permanent fixture; the Settings screen still shows the cache stats.
|
||||
React.useEffect(() => {
|
||||
if (sync.phase === 'done' || sync.phase === 'cancelled') {
|
||||
const t = setTimeout(() => resetSync(), 4000);
|
||||
setHideTimer(t);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
return undefined;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sync.phase, resetSync]);
|
||||
|
||||
if (sync.phase === 'idle') return null;
|
||||
|
||||
const pct =
|
||||
sync.total > 0
|
||||
? Math.min(100, Math.round((sync.completed / sync.total) * 100))
|
||||
: sync.phase === 'done' ? 100 : 0;
|
||||
|
||||
let title = 'Offline sync';
|
||||
let subtitle = '';
|
||||
switch (sync.phase) {
|
||||
case 'scanning':
|
||||
title = 'Syncing offline mail';
|
||||
subtitle = 'Scanning recent messages…';
|
||||
break;
|
||||
case 'fetching':
|
||||
title = 'Syncing offline mail';
|
||||
subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`;
|
||||
break;
|
||||
case 'done':
|
||||
title = 'Offline mail ready';
|
||||
subtitle = sync.fetched > 0
|
||||
? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}`
|
||||
: 'Already up to date';
|
||||
break;
|
||||
case 'cancelled':
|
||||
title = 'Sync cancelled';
|
||||
subtitle = `${sync.completed}/${sync.total} processed`;
|
||||
break;
|
||||
case 'error':
|
||||
title = 'Offline sync failed';
|
||||
subtitle = sync.message ?? 'Unable to download';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const showCancel = sync.phase === 'scanning' || sync.phase === 'fetching';
|
||||
const showDismiss = sync.phase === 'done' || sync.phase === 'cancelled' || sync.phase === 'error';
|
||||
const isError = sync.phase === 'error';
|
||||
|
||||
return (
|
||||
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
|
||||
<CloudDownload size={16} color={c.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{!!subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
||||
{(sync.phase === 'fetching' || sync.phase === 'scanning') && (
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${pct}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{showCancel && (
|
||||
<Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{showDismiss && (
|
||||
<Pressable style={styles.dismiss} onPress={resetSync} hitSlop={8}>
|
||||
<X size={14} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
bannerError: { backgroundColor: c.error },
|
||||
title: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85, marginTop: 2 },
|
||||
progressTrack: {
|
||||
marginTop: 6,
|
||||
height: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: c.primaryForeground,
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
cancelText: { ...typography.captionMedium, color: c.primaryForeground },
|
||||
dismiss: { padding: 4 },
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface RadioOption {
|
||||
label: string;
|
||||
@@ -22,6 +23,8 @@ interface RadioGroupProps {
|
||||
* - unselected: bg-muted text-foreground
|
||||
*/
|
||||
export default function RadioGroup({ options, value, onChange, style }: RadioGroupProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{options.map((opt) => {
|
||||
@@ -42,7 +45,8 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
gap: 6, // gap-1.5
|
||||
@@ -53,19 +57,20 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.sm, // rounded-md
|
||||
},
|
||||
optionSelected: {
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
optionUnselected: {
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
optionText: {
|
||||
...typography.caption, // text-xs
|
||||
},
|
||||
textSelected: {
|
||||
color: colors.primaryForeground,
|
||||
color: c.primaryForeground,
|
||||
fontWeight: '500',
|
||||
},
|
||||
textUnselected: {
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
const MIN_HEIGHT = 220;
|
||||
|
||||
export type RichTextCommand =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| 'strikeThrough'
|
||||
| 'insertUnorderedList'
|
||||
| 'insertOrderedList'
|
||||
| 'formatBlock:H1'
|
||||
| 'formatBlock:H2'
|
||||
| 'formatBlock:BLOCKQUOTE'
|
||||
| 'formatBlock:P'
|
||||
| 'justifyLeft'
|
||||
| 'justifyCenter'
|
||||
| 'justifyRight'
|
||||
| 'removeFormat'
|
||||
| 'undo'
|
||||
| 'redo';
|
||||
|
||||
export interface RichTextSelectionState {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikeThrough: boolean;
|
||||
ul: boolean;
|
||||
ol: boolean;
|
||||
blockquote: boolean;
|
||||
h1: boolean;
|
||||
h2: boolean;
|
||||
alignLeft: boolean;
|
||||
alignCenter: boolean;
|
||||
alignRight: boolean;
|
||||
link: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_STATE: RichTextSelectionState = {
|
||||
bold: false, italic: false, underline: false, strikeThrough: false,
|
||||
ul: false, ol: false, blockquote: false, h1: false, h2: false,
|
||||
alignLeft: false, alignCenter: false, alignRight: false, link: false,
|
||||
};
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
exec(command: RichTextCommand): void;
|
||||
insertLink(url: string, label?: string): void;
|
||||
unsetLink(): void;
|
||||
insertImage(src: string, cid?: string, alt?: string): void;
|
||||
setHtml(html: string): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialHtml?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (html: string) => void;
|
||||
onSelectionChange?: (state: RichTextSelectionState) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||
// user can't accidentally (or via a malicious paste) ship a
|
||||
// javascript:/data: link to their recipient.
|
||||
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
|
||||
var trimmed = (url || '').trim();
|
||||
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
|
||||
ref,
|
||||
) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [height, setHeight] = React.useState(MIN_HEIGHT);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
const onBlurRef = React.useRef(onBlur);
|
||||
onChangeRef.current = onChange;
|
||||
onSelectionRef.current = onSelectionChange;
|
||||
onFocusRef.current = onFocus;
|
||||
onBlurRef.current = onBlur;
|
||||
|
||||
// Build the source HTML once. Theming changes won't auto-rebuild the page,
|
||||
// which is fine: themes change rarely and the user can recompose if needed.
|
||||
const html = React.useMemo(
|
||||
() => buildEditorHtml({ initialHtml, placeholder, c }),
|
||||
// c reference changes per theme switch but we don't want to lose user
|
||||
// edits on a theme change. Bake in the initial colors only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const call = React.useCallback((script: string) => {
|
||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
exec: (command) => {
|
||||
const [name, value] = command.split(':');
|
||||
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
|
||||
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
|
||||
},
|
||||
insertLink: (url, label) => {
|
||||
call(`window.__rne && window.__rne.insertLink(${JSON.stringify(url)}, ${JSON.stringify(label ?? '')})`);
|
||||
},
|
||||
unsetLink: () => {
|
||||
call(`window.__rne && window.__rne.unsetLink()`);
|
||||
},
|
||||
insertImage: (src, cid, alt) => {
|
||||
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
|
||||
},
|
||||
setHtml: (next) => {
|
||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||
},
|
||||
focus: () => {
|
||||
call(`window.__rne && window.__rne.focus()`);
|
||||
},
|
||||
}), [call]);
|
||||
|
||||
const onMessage = (event: WebViewMessageEvent) => {
|
||||
let data: { type: string; payload: unknown };
|
||||
try {
|
||||
data = JSON.parse(event.nativeEvent.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (data.type) {
|
||||
case 'change':
|
||||
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
|
||||
break;
|
||||
case 'height': {
|
||||
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE);
|
||||
break;
|
||||
case 'focus':
|
||||
onFocusRef.current?.();
|
||||
break;
|
||||
case 'blur':
|
||||
onBlurRef.current?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { height }]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={['about:blank']}
|
||||
source={{ html }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled={false}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
hideKeyboardAccessoryView
|
||||
setSupportMultipleWindows={false}
|
||||
thirdPartyCookiesEnabled={false}
|
||||
sharedCookiesEnabled={false}
|
||||
allowFileAccess={false}
|
||||
allowFileAccessFromFileURLs={false}
|
||||
allowUniversalAccessFromFileURLs={false}
|
||||
allowsBackForwardNavigationGestures={false}
|
||||
incognito
|
||||
cacheEnabled={false}
|
||||
mixedContentMode="never"
|
||||
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
|
||||
style={styles.webview}
|
||||
containerStyle={styles.webviewContainer}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
// Allow only the initial about:blank doc + data URIs we emit
|
||||
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
|
||||
// scheme so a paste-injected anchor can't navigate the editor.
|
||||
const url = request.url;
|
||||
return !url || url === 'about:blank' || url.startsWith('data:');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default RichTextEditor;
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
webview: {
|
||||
backgroundColor: 'transparent',
|
||||
flex: 1,
|
||||
},
|
||||
webviewContainer: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
||||
Clock, Layers,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useAnimDuration } from '../theme/dynamic';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
@@ -48,21 +51,23 @@ function iconFor(
|
||||
return Folder;
|
||||
}
|
||||
|
||||
const ROLE_COLOR: Record<string, string> = {
|
||||
const ROLE_COLOR_FIXED: Record<string, string> = {
|
||||
inbox: '#60a5fa',
|
||||
sent: '#4ade80',
|
||||
drafts: '#a78bfa',
|
||||
trash: colors.textMuted,
|
||||
junk: '#f87171',
|
||||
archive: '#fbbf24',
|
||||
};
|
||||
|
||||
function iconColor(role: string | null | undefined, isSelected: boolean): string {
|
||||
if (role && ROLE_COLOR[role]) return ROLE_COLOR[role];
|
||||
return isSelected ? colors.text : colors.textSecondary;
|
||||
function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string {
|
||||
if (role === 'trash') return c.textMuted;
|
||||
if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role];
|
||||
return isSelected ? c.text : c.textSecondary;
|
||||
}
|
||||
|
||||
function RowCounts({ unread, total }: { unread: number; total: number }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
if (unread === 0 && total === 0) return null;
|
||||
return (
|
||||
<View style={styles.counts}>
|
||||
@@ -90,6 +95,8 @@ function SidebarRow({
|
||||
icon, label, depth, isSelected, unread, total,
|
||||
hasChildren, isExpanded, onPress, onToggleExpand,
|
||||
}: SidebarRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
|
||||
return (
|
||||
<Pressable
|
||||
@@ -108,9 +115,9 @@ function SidebarRow({
|
||||
style={styles.chevron}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} color={colors.textMuted} />
|
||||
<ChevronDown size={12} color={c.textMuted} />
|
||||
) : (
|
||||
<ChevronRight size={12} color={colors.textMuted} />
|
||||
<ChevronRight size={12} color={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
@@ -135,6 +142,8 @@ interface SidebarDrawerProps {
|
||||
}
|
||||
|
||||
export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const selectMailbox = useEmailStore((s) => s.selectMailbox);
|
||||
@@ -216,20 +225,22 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
|
||||
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const openDuration = useAnimDuration(240);
|
||||
const closeDuration = useAnimDuration(200);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideX, overlayOpacity]);
|
||||
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
|
||||
|
||||
const accountEmail = username || '';
|
||||
const initials = React.useMemo(
|
||||
@@ -237,7 +248,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
[accountEmail],
|
||||
);
|
||||
const avatarBg = React.useMemo(
|
||||
() => (accountEmail ? generateAvatarColor(accountEmail) : colors.primary),
|
||||
() => (accountEmail ? generateAvatarColor(accountEmail) : c.primary),
|
||||
[accountEmail],
|
||||
);
|
||||
const hostname = React.useMemo(() => {
|
||||
@@ -267,7 +278,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
{/* Header: close + account switcher */}
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||
<X size={20} color={colors.text} />
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setAccountMenuOpen((v) => !v)}
|
||||
@@ -284,7 +295,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
</View>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
color={colors.textMuted}
|
||||
color={c.textMuted}
|
||||
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
|
||||
/>
|
||||
</Pressable>
|
||||
@@ -323,7 +334,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
</View>
|
||||
{isActive && (
|
||||
<View style={styles.accountMenuCheckBadge}>
|
||||
<Check size={10} color={colors.primaryForeground} strokeWidth={3} />
|
||||
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -364,7 +375,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
navigation.navigate('AddAccount');
|
||||
}}
|
||||
>
|
||||
<Plus size={16} color={colors.textSecondary} />
|
||||
<Plus size={16} color={c.textSecondary} />
|
||||
<Text style={styles.accountMenuActionText}>Add account</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
@@ -382,7 +393,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
]}
|
||||
onPress={() => setDefaultAccount(active.id)}
|
||||
>
|
||||
<Star size={16} color={colors.textSecondary} />
|
||||
<Star size={16} color={c.textSecondary} />
|
||||
<Text style={styles.accountMenuActionText}>Set as default</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -394,7 +405,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
]}
|
||||
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
|
||||
>
|
||||
<LogOut size={16} color={colors.textSecondary} />
|
||||
<LogOut size={16} color={c.textSecondary} />
|
||||
<Text style={styles.accountMenuActionText} numberOfLines={1}>
|
||||
Sign out of {accountEmailFull || 'account'}
|
||||
</Text>
|
||||
@@ -407,8 +418,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
]}
|
||||
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
|
||||
>
|
||||
<LogOut size={16} color={colors.error} />
|
||||
<Text style={[styles.accountMenuActionText, { color: colors.error }]}>
|
||||
<LogOut size={16} color={c.error} />
|
||||
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
|
||||
Sign out all
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -417,17 +428,35 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
)}
|
||||
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||
{/* Quick views */}
|
||||
{accounts.length > 1 && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
|
||||
onPress={() => { onClose(); navigation.navigate('UnifiedInbox'); }}
|
||||
>
|
||||
<Layers size={16} color={c.textSecondary} />
|
||||
<Text style={styles.quickRowLabel}>All inboxes</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
|
||||
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
|
||||
>
|
||||
<Clock size={16} color={c.textSecondary} />
|
||||
<Text style={styles.quickRowLabel}>Scheduled</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Folders section */}
|
||||
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
|
||||
{foldersExpanded ? (
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
<ChevronDown size={14} color={c.textMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={colors.textMuted} />
|
||||
<ChevronRight size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.sectionHeaderText}>Folders</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<View style={styles.sectionSettings}>
|
||||
<Settings size={14} color={colors.textMuted} />
|
||||
<Settings size={14} color={c.textMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -443,7 +472,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
return (
|
||||
<SidebarRow
|
||||
key={node.id}
|
||||
icon={<Icon size={16} color={iconColor(node.role, isSelected)} />}
|
||||
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
|
||||
label={node.name}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
@@ -465,7 +494,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
@@ -476,9 +506,11 @@ const styles = StyleSheet.create({
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
|
||||
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
|
||||
// dark-mode value, which left light mode unreadable (dark bg + dark text).
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.border,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
drawerSafe: { flex: 1 },
|
||||
|
||||
@@ -490,7 +522,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
headerClose: {
|
||||
width: 36, height: 36,
|
||||
@@ -506,7 +538,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
accountPressed: { backgroundColor: colors.surfaceHover },
|
||||
accountPressed: { backgroundColor: c.surfaceHover },
|
||||
accountChevronOpen: { transform: [{ rotate: '180deg' }] },
|
||||
accountAvatar: {
|
||||
width: 36, height: 36,
|
||||
@@ -515,21 +547,21 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
accountAvatarText: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.primaryForeground,
|
||||
color: c.primaryForeground,
|
||||
},
|
||||
accountInfo: { flex: 1, minWidth: 0 },
|
||||
accountName: { ...typography.bodyMedium, color: colors.text },
|
||||
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
||||
accountName: { ...typography.bodyMedium, color: c.text },
|
||||
accountEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
|
||||
// Account menu - floating popover card under the header
|
||||
accountMenu: {
|
||||
marginHorizontal: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: '#1f1f1f',
|
||||
backgroundColor: c.popover,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
overflow: 'hidden',
|
||||
// Elevation / shadow
|
||||
shadowColor: '#000',
|
||||
@@ -566,14 +598,14 @@ const styles = StyleSheet.create({
|
||||
accountMenuAvatarText: {
|
||||
...typography.caption,
|
||||
fontWeight: '600',
|
||||
color: colors.primaryForeground,
|
||||
color: c.primaryForeground,
|
||||
},
|
||||
accountMenuCheckBadge: {
|
||||
position: 'absolute',
|
||||
right: -3, bottom: -3,
|
||||
width: 16, height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: '#1f1f1f',
|
||||
@@ -586,13 +618,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
accountMenuName: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
flexShrink: 1,
|
||||
fontWeight: '600',
|
||||
},
|
||||
accountMenuHost: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
marginTop: 2,
|
||||
},
|
||||
accountMenuStatusRow: {
|
||||
@@ -607,17 +639,17 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#22c55e',
|
||||
},
|
||||
accountMenuStatusDotOffline: {
|
||||
backgroundColor: colors.textMuted,
|
||||
backgroundColor: c.textMuted,
|
||||
},
|
||||
accountMenuStatusText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
accountMenuDivider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
accountMenuAction: {
|
||||
flexDirection: 'row',
|
||||
@@ -627,10 +659,10 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 10,
|
||||
minHeight: 40,
|
||||
},
|
||||
accountMenuActionPressed: { backgroundColor: colors.surfaceHover },
|
||||
accountMenuActionPressed: { backgroundColor: c.surfaceHover },
|
||||
accountMenuActionText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
flexShrink: 1,
|
||||
fontSize: 13,
|
||||
},
|
||||
@@ -650,13 +682,24 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sectionHeaderText: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
sectionSettings: { padding: 4 },
|
||||
|
||||
quickRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
minHeight: 44,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: 'transparent',
|
||||
},
|
||||
quickRowLabel: { ...typography.body, color: c.text },
|
||||
|
||||
empty: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
@@ -670,10 +713,10 @@ const styles = StyleSheet.create({
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: 'transparent',
|
||||
},
|
||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowSelected: {
|
||||
backgroundColor: colors.accent,
|
||||
borderLeftColor: colors.primary,
|
||||
backgroundColor: c.accent,
|
||||
borderLeftColor: c.primary,
|
||||
},
|
||||
rowIndent: {
|
||||
flexDirection: 'row',
|
||||
@@ -692,11 +735,11 @@ const styles = StyleSheet.create({
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
rowLabelSelected: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
counts: {
|
||||
flexDirection: 'row',
|
||||
@@ -707,16 +750,17 @@ const styles = StyleSheet.create({
|
||||
countUnread: {
|
||||
...typography.caption,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
countSep: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
opacity: 0.6,
|
||||
},
|
||||
countTotal: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: React.ReactNode;
|
||||
/** Right-to-left swipe action (revealed under a leftward drag, sits at right edge). */
|
||||
leftAction: SwipeAction;
|
||||
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
|
||||
rightAction: SwipeAction;
|
||||
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
|
||||
context: { unread: boolean; starred: boolean; pinned: boolean };
|
||||
onAction: (action: SwipeAction) => void;
|
||||
/**
|
||||
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
|
||||
* 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
|
||||
*/
|
||||
mode?: SwipeMode;
|
||||
}
|
||||
|
||||
// Instant mode: distance the user must drag past on release to fire the action.
|
||||
const COMMIT_THRESHOLD = 96;
|
||||
// Reveal mode: drag past this on release to snap to the open (revealed) state.
|
||||
const ACTIVATION_THRESHOLD = 32;
|
||||
// Reveal mode: width of the action band shown when revealed.
|
||||
const REVEAL_WIDTH = 88;
|
||||
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
|
||||
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
|
||||
// Distance the row flies off-screen by before the action callback fires.
|
||||
const EXIT_DISTANCE = 600;
|
||||
|
||||
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
|
||||
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
|
||||
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
|
||||
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
|
||||
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
|
||||
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
|
||||
pin: { icon: Pin, bg: '#7c3aed', defaultLabel: 'Pin' },
|
||||
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
|
||||
};
|
||||
|
||||
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string {
|
||||
if (action === 'read') return context.unread ? 'Read' : 'Unread';
|
||||
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
|
||||
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin';
|
||||
if (action === 'none') return '';
|
||||
return ACTION_META[action].defaultLabel;
|
||||
}
|
||||
|
||||
// Actions that visibly remove the row from the list (so the row should fly off
|
||||
// instead of snapping back). Toggle-style actions stay in place.
|
||||
function exitsRow(action: SwipeAction): boolean {
|
||||
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
|
||||
}
|
||||
|
||||
export function SwipeableRow({
|
||||
children, leftAction, rightAction, context, onAction, mode = 'instant',
|
||||
}: SwipeableRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const dx = useRef(new Animated.Value(0)).current;
|
||||
const claimed = useRef(false);
|
||||
const widthRef = useRef(Dimensions.get('window').width);
|
||||
|
||||
// Reveal-mode-only state: which side (if any) is currently sitting open.
|
||||
const openSideRef = useRef<'left' | 'right' | null>(null);
|
||||
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
|
||||
|
||||
const close = () => {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = null;
|
||||
setOpenSide(null);
|
||||
};
|
||||
|
||||
const openTo = (side: 'left' | 'right') => {
|
||||
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
|
||||
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = side;
|
||||
setOpenSide(side);
|
||||
};
|
||||
|
||||
const fly = (toValue: number, action: SwipeAction) => {
|
||||
// For destructive/move actions: race the row off-screen and fire the
|
||||
// action - the parent will remove the row from the list. For toggle
|
||||
// actions: fire immediately and snap back so the same row can update in
|
||||
// place.
|
||||
if (exitsRow(action)) {
|
||||
Animated.timing(dx, {
|
||||
toValue,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onAction(action);
|
||||
// The row is about to be removed; reset translation in case it isn't
|
||||
// (e.g. the action failed silently) so we don't leave it off-screen.
|
||||
dx.setValue(0);
|
||||
});
|
||||
} else {
|
||||
onAction(action);
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
};
|
||||
|
||||
const fireFromBandTap = (action: SwipeAction) => {
|
||||
close();
|
||||
if (action === 'none') return;
|
||||
if (exitsRow(action)) {
|
||||
// Let the row collapse a frame, then fire so the parent's list update
|
||||
// has a clean starting point.
|
||||
requestAnimationFrame(() => onAction(action));
|
||||
} else {
|
||||
onAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
const responder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||
// Reveal mode: if a side is already open, allow the gesture so the
|
||||
// user can drag it closed.
|
||||
if (mode === 'reveal' && openSideRef.current === null) {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
} else if (mode !== 'reveal') {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
}
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
|
||||
const base =
|
||||
mode === 'reveal'
|
||||
? openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0
|
||||
: 0;
|
||||
const next = base + g.dx;
|
||||
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
|
||||
dx.setValue(clamped);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
claimed.current = false;
|
||||
if (mode === 'reveal') {
|
||||
const base =
|
||||
openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0;
|
||||
const finalDx = base + g.dx;
|
||||
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||
openTo('right');
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||
openTo('left');
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, rightAction);
|
||||
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
|
||||
} else {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (mode === 'reveal') close();
|
||||
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
).current;
|
||||
|
||||
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
|
||||
widthRef.current = e.nativeEvent.layout.width;
|
||||
};
|
||||
|
||||
if (mode === 'reveal') {
|
||||
// Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
|
||||
// are tap targets. The row slides over them; releasing past the activation
|
||||
// threshold snaps the row to the open position so the band is fully
|
||||
// visible and tappable.
|
||||
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
if (action === 'none') return null;
|
||||
const meta = ACTION_META[action];
|
||||
const Icon = meta.icon;
|
||||
const label = actionLabel(action, context);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => fireFromBandTap(action)}
|
||||
style={[
|
||||
styles.bandReveal,
|
||||
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
|
||||
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
|
||||
]}
|
||||
>
|
||||
<View style={styles.bandInner}>
|
||||
<Icon size={20} color="#fff" />
|
||||
<Text style={styles.bandLabel}>{label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
|
||||
{renderRevealBand(rightAction, 'left')}
|
||||
{renderRevealBand(leftAction, 'right')}
|
||||
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
|
||||
{children}
|
||||
{/* When an action is revealed, an overlay absorbs taps on the row so
|
||||
a tap closes the swipe instead of opening the email. */}
|
||||
{openSide ? (
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={close}
|
||||
/>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Instant mode: bands fill the gap behind the row as it's dragged.
|
||||
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
if (action === 'none') return null;
|
||||
const meta = ACTION_META[action];
|
||||
const Icon = meta.icon;
|
||||
const label = actionLabel(action, context);
|
||||
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
|
||||
const iconScale = dx.interpolate({
|
||||
inputRange,
|
||||
outputRange: side === 'left' ? [0.85, 1.15] : [1.15, 0.85],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.bandInstant,
|
||||
{ backgroundColor: meta.bg },
|
||||
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.bandInner, { transform: [{ scale: iconScale }] }]}>
|
||||
<Icon size={22} color="#fff" />
|
||||
<Text style={styles.bandLabel}>{label}</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const rightBandOpacity = dx.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [0, 0, 1],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
const leftBandOpacity = dx.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [1, 0, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
|
||||
{renderInstantBand(rightAction, 'left')}
|
||||
</Animated.View>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
|
||||
{renderInstantBand(leftAction, 'right')}
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
|
||||
content: { backgroundColor: c.background },
|
||||
bandInstant: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
bandReveal: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
bandInner: { alignItems: 'center', gap: 4 },
|
||||
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Tag } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
interface TagSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
keywords: KeywordDef[];
|
||||
/** The currently-selected emails, used to show which tags are already set. */
|
||||
selectedEmails: Email[];
|
||||
/** Toggle a keyword token on/off across the whole selection. */
|
||||
onToggle: (token: string, on: boolean) => void;
|
||||
}
|
||||
|
||||
export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
// A tag counts as "applied" only when every selected email carries it; in
|
||||
// that case tapping removes it, otherwise tapping adds it to all.
|
||||
const allHaveToken = React.useCallback(
|
||||
(token: string) =>
|
||||
selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.[token]),
|
||||
[selectedEmails],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Tag {selectedEmails.length} selected</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={styles.empty}>No tags defined. Add tags in Settings.</Text>
|
||||
) : (
|
||||
keywords.map((kw) => {
|
||||
const token = keywordToken(kw.id);
|
||||
const applied = allHaveToken(token);
|
||||
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
|
||||
return (
|
||||
<Pressable
|
||||
key={kw.id}
|
||||
onPress={() => onToggle(token, !applied)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Tag size={16} color={dot} fill={dot} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
|
||||
{applied && <Check size={16} color={c.primary} />}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
empty: { ...typography.body, color: c.textMuted, padding: spacing.lg, textAlign: 'center' },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Pressable, View, StyleSheet } from 'react-native';
|
||||
import { colors, radius, componentSizes } from '../theme/tokens';
|
||||
import { radius, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
value: boolean;
|
||||
@@ -16,6 +17,8 @@ interface ToggleSwitchProps {
|
||||
* - translate-x-6 (checked) / translate-x-1 (unchecked)
|
||||
*/
|
||||
export default function ToggleSwitch({ value, onValueChange, disabled = false }: ToggleSwitchProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
@@ -37,7 +40,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false }:
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
track: {
|
||||
width: componentSizes.toggleWidth, // w-11 = 44
|
||||
height: componentSizes.toggleHeight, // h-6 = 24
|
||||
@@ -45,18 +49,19 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
trackOn: {
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
trackOff: {
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
thumb: {
|
||||
width: componentSizes.toggleThumb, // h-4 w-4 = 16
|
||||
height: componentSizes.toggleThumb,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
|
||||
const VISIBLE_MS = 5000;
|
||||
|
||||
export function UndoSnackbar() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const entry = useEmailStore((s) => s.pendingUndo);
|
||||
const undoLast = useEmailStore((s) => s.undoLast);
|
||||
const clearUndo = useEmailStore((s) => s.clearUndo);
|
||||
const slideY = React.useRef(new Animated.Value(120)).current;
|
||||
const opacity = React.useRef(new Animated.Value(0)).current;
|
||||
// Remember the last non-null entry so the bar's text/handlers stay valid
|
||||
// while it animates out after pendingUndo flips to null.
|
||||
const [shown, setShown] = React.useState(entry);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (entry) setShown(entry);
|
||||
}, [entry]);
|
||||
|
||||
// Drive the auto-dismiss off the entry's createdAt so re-renders during
|
||||
// animation don't reset the timer, and so consecutive actions reset it.
|
||||
React.useEffect(() => {
|
||||
if (!entry) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) setShown(null);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
|
||||
const elapsed = Date.now() - entry.createdAt;
|
||||
const remaining = Math.max(0, VISIBLE_MS - elapsed);
|
||||
const id = setTimeout(() => {
|
||||
// Only clear if the entry is still the same one we scheduled for.
|
||||
if (useEmailStore.getState().pendingUndo === entry) {
|
||||
clearUndo();
|
||||
}
|
||||
}, remaining);
|
||||
return () => clearTimeout(id);
|
||||
}, [entry, slideY, opacity, clearUndo]);
|
||||
|
||||
if (!shown) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.wrap,
|
||||
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
|
||||
<Pressable
|
||||
onPress={() => { void undoLast(); }}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||
>
|
||||
<Text style={styles.undoText}>UNDO</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm + 2,
|
||||
paddingLeft: spacing.lg,
|
||||
paddingRight: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.text,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 8,
|
||||
},
|
||||
label: {
|
||||
...typography.body,
|
||||
color: c.background,
|
||||
flex: 1,
|
||||
},
|
||||
undoBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
undoBtnPressed: {
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
undoText: {
|
||||
...typography.bodySemibold,
|
||||
color: c.primary,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Download, X } from 'lucide-react-native';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
export function UpdateBanner(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const installing = useUpdatesStore((s) => s.installing);
|
||||
@@ -17,8 +22,8 @@ export function UpdateBanner(): React.ReactElement | null {
|
||||
if (!cachedLatest?.apkAsset) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<Download size={16} color={colors.primaryForeground} />
|
||||
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
|
||||
<Download size={16} color={c.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>Update available</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
@@ -33,29 +38,31 @@ export function UpdateBanner(): React.ReactElement | null {
|
||||
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
|
||||
<X size={14} color={colors.primaryForeground} />
|
||||
<X size={14} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: colors.primaryForeground },
|
||||
subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 },
|
||||
title: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85 },
|
||||
installButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
installText: { ...typography.captionMedium, color: colors.primaryForeground },
|
||||
installText: { ...typography.captionMedium, color: c.primaryForeground },
|
||||
dismiss: { padding: 4 },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
|
||||
import { CalendarDays } from 'lucide-react-native';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { colors, spacing, typography } from '../../theme/tokens';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
@@ -41,6 +42,8 @@ export function AgendaView({
|
||||
eventsByDay,
|
||||
onSelectEvent,
|
||||
}: AgendaViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const index = React.useMemo(
|
||||
() => eventsByDay ?? buildEventDayIndex(events),
|
||||
[eventsByDay, events],
|
||||
@@ -78,7 +81,7 @@ export function AgendaView({
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<CalendarDays size={40} color={colors.surfaceActive} />
|
||||
<CalendarDays size={40} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No upcoming events</Text>
|
||||
<Text style={styles.emptySubtitle}>The next {daysAhead} days are clear.</Text>
|
||||
</View>
|
||||
@@ -118,7 +121,8 @@ export function AgendaView({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
listContent: { paddingBottom: 80 },
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -126,16 +130,16 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sectionTitle: { ...typography.bodyMedium, color: colors.text },
|
||||
sectionTitleToday: { color: colors.primary },
|
||||
sectionSub: { ...typography.caption, color: colors.textMuted },
|
||||
sectionTitle: { ...typography.bodyMedium, color: c.text },
|
||||
sectionTitleToday: { color: c.primary },
|
||||
sectionSub: { ...typography.caption, color: c.textMuted },
|
||||
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
||||
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||
emptyDayText: { ...typography.caption, color: colors.textMuted, textAlign: 'center' },
|
||||
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
@@ -143,6 +147,7 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xxxl,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Check, X } from 'lucide-react-native';
|
||||
import { Check, X, Upload, Rss } from 'lucide-react-native';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
|
||||
interface CalendarSidebarDrawerProps {
|
||||
@@ -22,6 +24,8 @@ interface CalendarSidebarDrawerProps {
|
||||
hiddenCalendarIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onClose: () => void;
|
||||
onImport?: () => void;
|
||||
onManageSubscriptions?: () => void;
|
||||
}
|
||||
|
||||
export function CalendarSidebarDrawer({
|
||||
@@ -30,22 +34,28 @@ export function CalendarSidebarDrawer({
|
||||
hiddenCalendarIds,
|
||||
onToggle,
|
||||
onClose,
|
||||
onImport,
|
||||
onManageSubscriptions,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const openDuration = useAnimDuration(240);
|
||||
const closeDuration = useAnimDuration(200);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideX, {
|
||||
toValue: 0,
|
||||
duration: 240,
|
||||
duration: openDuration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 240,
|
||||
duration: openDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
@@ -53,18 +63,18 @@ export function CalendarSidebarDrawer({
|
||||
Animated.parallel([
|
||||
Animated.timing(slideX, {
|
||||
toValue: -Dimensions.get('window').width,
|
||||
duration: 200,
|
||||
duration: closeDuration,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
duration: closeDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideX, overlayOpacity]);
|
||||
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
|
||||
|
||||
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
|
||||
|
||||
@@ -89,7 +99,7 @@ export function CalendarSidebarDrawer({
|
||||
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||
<X size={20} color={colors.text} />
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>Calendars</Text>
|
||||
</View>
|
||||
@@ -116,6 +126,29 @@ export function CalendarSidebarDrawer({
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(onImport || onManageSubscriptions) && (
|
||||
<View style={styles.actionsSection}>
|
||||
{onManageSubscriptions && (
|
||||
<Pressable
|
||||
onPress={onManageSubscriptions}
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Rss size={18} color={c.textSecondary} />
|
||||
<Text style={styles.actionText}>Subscriptions</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{onImport && (
|
||||
<Pressable
|
||||
onPress={onImport}
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Upload size={18} color={c.textSecondary} />
|
||||
<Text style={styles.actionText}>Import from file</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
@@ -134,15 +167,17 @@ function Section({
|
||||
hiddenSet: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
{calendars.map((c) => {
|
||||
const visible = !hiddenSet.has(c.id);
|
||||
{calendars.map((cal) => {
|
||||
const visible = !hiddenSet.has(cal.id);
|
||||
return (
|
||||
<Pressable
|
||||
key={c.id}
|
||||
onPress={() => onToggle(c.id)}
|
||||
key={cal.id}
|
||||
onPress={() => onToggle(cal.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
@@ -152,15 +187,15 @@ function Section({
|
||||
style={[
|
||||
styles.swatch,
|
||||
{
|
||||
backgroundColor: visible ? getCalendarColor(c) : 'transparent',
|
||||
borderColor: getCalendarColor(c),
|
||||
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
|
||||
borderColor: getCalendarColor(cal),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{visible && <Check size={12} color={colors.textInverse} />}
|
||||
{visible && <Check size={12} color={c.textInverse} />}
|
||||
</View>
|
||||
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
|
||||
{c.name}
|
||||
{cal.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -169,7 +204,8 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
@@ -182,9 +218,9 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.border,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
drawerSafe: { flex: 1 },
|
||||
|
||||
@@ -195,7 +231,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
headerClose: {
|
||||
width: 36,
|
||||
@@ -204,14 +240,14 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text },
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: { paddingBottom: spacing.lg },
|
||||
|
||||
empty: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
@@ -219,7 +255,7 @@ const styles = StyleSheet.create({
|
||||
section: { paddingTop: spacing.md },
|
||||
sectionTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
@@ -231,7 +267,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
swatch: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
@@ -240,6 +276,23 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
rowName: { flex: 1, ...typography.body, color: colors.text },
|
||||
rowNameMuted: { color: colors.textMuted },
|
||||
});
|
||||
rowName: { flex: 1, ...typography.body, color: c.text },
|
||||
rowNameMuted: { color: c.textMuted },
|
||||
|
||||
actionsSection: {
|
||||
marginTop: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 44,
|
||||
},
|
||||
actionText: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Clock, MapPin, Users } from 'lucide-react-native';
|
||||
import { format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
|
||||
|
||||
interface EventCardProps {
|
||||
@@ -24,6 +25,8 @@ function formatTimeRange(event: CalendarEvent): string {
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const color = getEventColor(event, calendars);
|
||||
const time = formatTimeRange(event);
|
||||
const count = participantCount(event);
|
||||
@@ -48,13 +51,13 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
|
||||
</View>
|
||||
{!event.showWithoutTime && (
|
||||
<View style={styles.detailRow}>
|
||||
<Clock size={12} color={colors.textMuted} />
|
||||
<Clock size={12} color={c.textMuted} />
|
||||
<Text style={styles.detailText}>{time}</Text>
|
||||
</View>
|
||||
)}
|
||||
{event.description ? (
|
||||
<View style={styles.detailRow}>
|
||||
<MapPin size={12} color={colors.textMuted} />
|
||||
<MapPin size={12} color={c.textMuted} />
|
||||
<Text style={styles.detailText} numberOfLines={1}>
|
||||
{event.description}
|
||||
</Text>
|
||||
@@ -62,7 +65,7 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
|
||||
) : null}
|
||||
{count > 0 && (
|
||||
<View style={styles.detailRow}>
|
||||
<Users size={12} color={colors.textMuted} />
|
||||
<Users size={12} color={c.textMuted} />
|
||||
<Text style={styles.detailText}>{count} participants</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -71,17 +74,18 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.card,
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
cardPressed: { backgroundColor: colors.surfaceHover },
|
||||
cardPressed: { backgroundColor: c.surfaceHover },
|
||||
colorBar: { width: 3 },
|
||||
body: { flex: 1, padding: spacing.md, gap: 4 },
|
||||
headerRow: {
|
||||
@@ -90,14 +94,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
||||
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
allDayBadge: {
|
||||
backgroundColor: colors.primaryBg,
|
||||
backgroundColor: c.primaryBg,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
allDayText: { ...typography.small, color: colors.primary },
|
||||
allDayText: { ...typography.small, color: c.primary },
|
||||
detailRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
detailText: { ...typography.caption, color: colors.textMuted, flexShrink: 1 },
|
||||
});
|
||||
detailText: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -15,23 +16,36 @@ import {
|
||||
AlignLeft,
|
||||
Bell,
|
||||
Calendar as CalendarIcon,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
HelpCircle,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Repeat,
|
||||
Trash2,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import { format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||
import {
|
||||
findParticipantByEmail,
|
||||
isOrganizerParticipant,
|
||||
} from '../../lib/calendar-invitation';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
@@ -40,6 +54,7 @@ interface EventDetailSheetProps {
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
onDuplicate?: (event: CalendarEvent) => void;
|
||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function formatRange(event: CalendarEvent): string {
|
||||
@@ -82,22 +97,6 @@ function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
return label;
|
||||
}
|
||||
|
||||
function alertLabel(event: CalendarEvent): string | null {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first) return null;
|
||||
const offset = first.trigger?.offset;
|
||||
if (!offset) return null;
|
||||
if (offset === 'PT0S' || offset === '-PT0S') return 'At time of event';
|
||||
const min = offset.match(/-?PT(\d+)M$/);
|
||||
if (min) return `${min[1]} minutes before`;
|
||||
const hr = offset.match(/-?PT(\d+)H$/);
|
||||
if (hr) return `${hr[1]} hours before`;
|
||||
const day = offset.match(/-?P(\d+)D/);
|
||||
if (day) return `${day[1]} days before`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EventDetailSheet({
|
||||
event,
|
||||
calendars,
|
||||
@@ -105,10 +104,20 @@ export function EventDetailSheet({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onRsvp,
|
||||
}: EventDetailSheetProps) {
|
||||
const c = useColors();
|
||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
||||
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const visible = !!event;
|
||||
const dragHandlers = useSheetDrag({
|
||||
slideY,
|
||||
closedY: Dimensions.get('window').height,
|
||||
onClose,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -149,8 +158,26 @@ export function EventDetailSheet({
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const range = formatRange(event);
|
||||
const recurrence = recurrenceLabel(event);
|
||||
const alert = alertLabel(event);
|
||||
const reminders = alertsToReminders(event.alerts);
|
||||
const participants = event.participants ? Object.values(event.participants) : [];
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||
|
||||
// Can the signed-in user RSVP? Only when they appear as a non-organizer
|
||||
// participant and the caller wired an onRsvp handler.
|
||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
||||
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
|
||||
const myStatus = me?.participant.participationStatus;
|
||||
|
||||
const doRsvp = async (status: RsvpStatus) => {
|
||||
if (!onRsvp || !me || rsvpBusy) return;
|
||||
setRsvpBusy(true);
|
||||
try {
|
||||
await onRsvp(event, me.id, status);
|
||||
} finally {
|
||||
setRsvpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -166,17 +193,21 @@ export function EventDetailSheet({
|
||||
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
|
||||
<View style={styles.handle} />
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
|
||||
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
|
||||
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<X size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<X size={20} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -184,37 +215,85 @@ export function EventDetailSheet({
|
||||
contentContainerStyle={styles.bodyContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<DetailRow icon={<Clock size={16} color={colors.textMuted} />} text={range} />
|
||||
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
|
||||
{location ? (
|
||||
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
|
||||
) : null}
|
||||
{videoUri ? (
|
||||
<View style={styles.detailRow}>
|
||||
<View style={styles.detailIcon}>
|
||||
<Video size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.joinBtn}
|
||||
onPress={() => { void Linking.openURL(videoUri); }}
|
||||
>
|
||||
<Text style={styles.joinBtnText}>Join video call</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{event.description ? (
|
||||
<DetailRow
|
||||
icon={<AlignLeft size={16} color={colors.textMuted} />}
|
||||
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||
text={event.description}
|
||||
multiline
|
||||
/>
|
||||
) : null}
|
||||
{recurrence && (
|
||||
<DetailRow
|
||||
icon={<Repeat size={16} color={colors.textMuted} />}
|
||||
icon={<Repeat size={16} color={c.textMuted} />}
|
||||
text={recurrence}
|
||||
/>
|
||||
)}
|
||||
{alert && (
|
||||
{reminders.length > 0 && (
|
||||
<DetailRow
|
||||
icon={<Bell size={16} color={colors.textMuted} />}
|
||||
text={alert}
|
||||
icon={<Bell size={16} color={c.textMuted} />}
|
||||
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
|
||||
/>
|
||||
)}
|
||||
{calendar && (
|
||||
<DetailRow
|
||||
icon={<CalendarIcon size={16} color={colors.textMuted} />}
|
||||
icon={<CalendarIcon size={16} color={c.textMuted} />}
|
||||
text={calendar.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canRsvp && (
|
||||
<View style={styles.rsvpBlock}>
|
||||
<Text style={styles.rsvpPrompt}>Going?</Text>
|
||||
<View style={styles.rsvpButtons}>
|
||||
<RsvpButton
|
||||
label="Yes"
|
||||
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
|
||||
active={myStatus === 'accepted'}
|
||||
activeColor={c.success}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('accepted')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="Maybe"
|
||||
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
|
||||
active={myStatus === 'tentative'}
|
||||
activeColor={c.warning}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('tentative')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="No"
|
||||
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
active={myStatus === 'declined'}
|
||||
activeColor={c.error}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('declined')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{participants.length > 0 && (
|
||||
<View style={styles.participantsBlock}>
|
||||
<View style={styles.participantsHeader}>
|
||||
<Users size={16} color={colors.textMuted} />
|
||||
<Users size={16} color={c.textMuted} />
|
||||
<Text style={styles.participantsHeaderText}>
|
||||
{participants.length} participants
|
||||
</Text>
|
||||
@@ -225,7 +304,7 @@ export function EventDetailSheet({
|
||||
style={[
|
||||
styles.participantDot,
|
||||
{
|
||||
backgroundColor: statusColor(p.participationStatus),
|
||||
backgroundColor: statusColor(c, p.participationStatus),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -244,21 +323,21 @@ export function EventDetailSheet({
|
||||
<View style={styles.actions}>
|
||||
{onEdit && (
|
||||
<ActionButton
|
||||
icon={<Pencil size={18} color={colors.text} />}
|
||||
icon={<Pencil size={18} color={c.text} />}
|
||||
label="Edit"
|
||||
onPress={() => onEdit(event)}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<ActionButton
|
||||
icon={<Copy size={18} color={colors.text} />}
|
||||
icon={<Copy size={18} color={c.text} />}
|
||||
label="Duplicate"
|
||||
onPress={() => onDuplicate(event)}
|
||||
/>
|
||||
)}
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
icon={<Trash2 size={18} color={colors.error} />}
|
||||
icon={<Trash2 size={18} color={c.error} />}
|
||||
label="Delete"
|
||||
onPress={() => onDelete(event)}
|
||||
destructive
|
||||
@@ -280,6 +359,8 @@ function DetailRow({
|
||||
text: string;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={styles.detailRow}>
|
||||
<View style={styles.detailIcon}>{icon}</View>
|
||||
@@ -301,6 +382,8 @@ function ActionButton({
|
||||
onPress: () => void;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -317,16 +400,49 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status?: string): string {
|
||||
function RsvpButton({
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
activeColor,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
activeColor: string;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.rsvpBtn,
|
||||
active && { backgroundColor: activeColor, borderColor: activeColor },
|
||||
disabled && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
{icon}
|
||||
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(c: ThemePalette, status?: string): string {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return colors.success;
|
||||
return c.success;
|
||||
case 'declined':
|
||||
return colors.error;
|
||||
return c.error;
|
||||
case 'tentative':
|
||||
return colors.warning;
|
||||
return c.warning;
|
||||
default:
|
||||
return colors.textMuted;
|
||||
return c.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +461,8 @@ function statusLabel(status?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
@@ -357,20 +474,23 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '85%',
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
sheetSafe: { paddingTop: spacing.sm },
|
||||
handleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
handle: {
|
||||
alignSelf: 'center',
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.surfaceActive,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: c.surfaceActive,
|
||||
},
|
||||
|
||||
header: {
|
||||
@@ -382,8 +502,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
|
||||
headerText: { flex: 1 },
|
||||
title: { ...typography.h3, color: colors.text },
|
||||
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
title: { ...typography.h3, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
closeBtn: { padding: 4 },
|
||||
|
||||
body: { maxHeight: 400 },
|
||||
@@ -394,7 +514,34 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
detailRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'flex-start' },
|
||||
detailIcon: { width: 16, paddingTop: 2 },
|
||||
detailText: { flex: 1, ...typography.body, color: colors.text },
|
||||
detailText: { flex: 1, ...typography.body, color: c.text },
|
||||
|
||||
joinBtn: {
|
||||
flex: 1,
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
joinBtnText: { ...typography.bodyMedium, color: c.primary },
|
||||
|
||||
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
|
||||
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
|
||||
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
|
||||
rsvpBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
rsvpBtnText: { ...typography.caption, color: c.text },
|
||||
|
||||
participantsBlock: {
|
||||
marginTop: spacing.sm,
|
||||
@@ -406,7 +553,7 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
participantsHeaderText: { ...typography.bodyMedium, color: colors.text },
|
||||
participantsHeaderText: { ...typography.bodyMedium, color: c.text },
|
||||
participantRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -414,13 +561,13 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 4,
|
||||
},
|
||||
participantDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
participantName: { flex: 1, ...typography.body, color: colors.text },
|
||||
participantStatus: { ...typography.caption, color: colors.textMuted },
|
||||
participantName: { flex: 1, ...typography.body, color: c.text },
|
||||
participantStatus: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
borderTopColor: c.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
@@ -432,7 +579,8 @@ const styles = StyleSheet.create({
|
||||
gap: 4,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
actionBtnPressed: { backgroundColor: colors.surfaceHover },
|
||||
actionLabel: { ...typography.caption, color: colors.text },
|
||||
actionLabelDestructive: { color: colors.error },
|
||||
});
|
||||
actionBtnPressed: { backgroundColor: c.surfaceHover },
|
||||
actionLabel: { ...typography.caption, color: c.text },
|
||||
actionLabelDestructive: { color: c.error },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,14 +17,24 @@ import {
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
MapPin,
|
||||
Video,
|
||||
Repeat,
|
||||
Bell,
|
||||
Plus,
|
||||
Users,
|
||||
Trash2,
|
||||
AlignLeft,
|
||||
} from 'lucide-react-native';
|
||||
import { addHours, addMinutes, format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
Participant,
|
||||
EventLocation,
|
||||
VirtualLocation,
|
||||
} from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildAllDayDuration,
|
||||
getCalendarColor,
|
||||
@@ -32,11 +42,17 @@ import {
|
||||
getEventStartDate,
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
formatReminder,
|
||||
REMINDER_PRESETS,
|
||||
type Reminder,
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { Button } from '..';
|
||||
import { ParticipantInput } from './ParticipantInput';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
type AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440';
|
||||
|
||||
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'none', label: 'Does not repeat' },
|
||||
@@ -46,16 +62,6 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'yearly', label: 'Yearly' },
|
||||
];
|
||||
|
||||
const ALERT_OPTIONS: { value: AlertOption; label: string }[] = [
|
||||
{ value: 'none', label: 'No reminder' },
|
||||
{ value: 'at_time', label: 'At time of event' },
|
||||
{ value: '5', label: '5 minutes before' },
|
||||
{ value: '15', label: '15 minutes before' },
|
||||
{ value: '30', label: '30 minutes before' },
|
||||
{ value: '60', label: '1 hour before' },
|
||||
{ value: '1440', label: '1 day before' },
|
||||
];
|
||||
|
||||
interface EventModalProps {
|
||||
visible: boolean;
|
||||
event?: CalendarEvent | null;
|
||||
@@ -100,39 +106,30 @@ function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOp
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function detectAlert(event: CalendarEvent | null | undefined): AlertOption {
|
||||
const alerts = event?.alerts;
|
||||
if (!alerts) return 'none';
|
||||
const first = Object.values(alerts)[0];
|
||||
if (!first) return 'none';
|
||||
const offset = first.trigger?.offset;
|
||||
if (!offset) return 'none';
|
||||
if (offset === 'PT0S' || offset === '-PT0S') return 'at_time';
|
||||
const min = offset.match(/-?PT(\d+)M$/);
|
||||
if (min) {
|
||||
const v = min[1];
|
||||
if (v === '5' || v === '15' || v === '30') return v as AlertOption;
|
||||
}
|
||||
const hr = offset.match(/-?PT(\d+)H$/);
|
||||
if (hr && hr[1] === '1') return '60';
|
||||
const day = offset.match(/-?P(\d+)D/);
|
||||
if (day && day[1] === '1') return '1440';
|
||||
return 'none';
|
||||
function detectLocation(event: CalendarEvent | null | undefined): string {
|
||||
const locations = event?.locations;
|
||||
if (!locations) return '';
|
||||
const first = Object.values(locations)[0];
|
||||
return first?.name || '';
|
||||
}
|
||||
|
||||
function alertFromOption(opt: AlertOption): CalendarEvent['alerts'] | undefined {
|
||||
if (opt === 'none') return undefined;
|
||||
let offset = '-PT0M';
|
||||
if (opt === 'at_time') offset = 'PT0S';
|
||||
else if (opt === '60') offset = '-PT1H';
|
||||
else if (opt === '1440') offset = '-P1D';
|
||||
else offset = `-PT${opt}M`;
|
||||
return {
|
||||
'a-1': {
|
||||
trigger: { '@type': 'OffsetTrigger', offset },
|
||||
action: 'display',
|
||||
},
|
||||
};
|
||||
function detectVideoUrl(event: CalendarEvent | null | undefined): string {
|
||||
const v = event?.virtualLocations;
|
||||
if (!v) return '';
|
||||
const first = Object.values(v)[0];
|
||||
return first?.uri || '';
|
||||
}
|
||||
|
||||
function buildLocations(name: string): Record<string, EventLocation> | undefined {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return { 'loc-1': { '@type': 'Location', name: trimmed } };
|
||||
}
|
||||
|
||||
function buildVirtualLocations(uri: string): Record<string, VirtualLocation> | undefined {
|
||||
const trimmed = uri.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return { 'vloc-1': { '@type': 'VirtualLocation', name: 'Video call', uri: trimmed } };
|
||||
}
|
||||
|
||||
function recurrenceFromOption(opt: RecurrenceOption): CalendarEvent['recurrenceRules'] | undefined {
|
||||
@@ -150,6 +147,8 @@ export function EventModal({
|
||||
onDelete,
|
||||
onClose,
|
||||
}: EventModalProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const isEdit = !!event;
|
||||
|
||||
const [title, setTitle] = React.useState('');
|
||||
@@ -160,9 +159,11 @@ export function EventModal({
|
||||
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
|
||||
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
|
||||
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
|
||||
const [alert, setAlert] = React.useState<AlertOption>('none');
|
||||
const [reminders, setReminders] = React.useState<Reminder[]>([]);
|
||||
const [location, setLocation] = React.useState('');
|
||||
const [videoUrl, setVideoUrl] = React.useState('');
|
||||
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
|
||||
const [alertOpen, setAlertOpen] = React.useState(false);
|
||||
const [reminderPickerOpen, setReminderPickerOpen] = React.useState(false);
|
||||
const [calendarOpen, setCalendarOpen] = React.useState(false);
|
||||
const [showStartDate, setShowStartDate] = React.useState(false);
|
||||
const [showStartTime, setShowStartTime] = React.useState(false);
|
||||
@@ -181,7 +182,9 @@ export function EventModal({
|
||||
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
||||
setParticipants(event.participants || {});
|
||||
setRecurrence(detectRecurrence(event));
|
||||
setAlert(detectAlert(event));
|
||||
setReminders(alertsToReminders(event.alerts));
|
||||
setLocation(detectLocation(event));
|
||||
setVideoUrl(detectVideoUrl(event));
|
||||
} else {
|
||||
const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date());
|
||||
setTitle('');
|
||||
@@ -192,7 +195,9 @@ export function EventModal({
|
||||
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
|
||||
setParticipants({});
|
||||
setRecurrence('none');
|
||||
setAlert('none');
|
||||
setReminders([]);
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
}
|
||||
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
|
||||
|
||||
@@ -237,7 +242,10 @@ export function EventModal({
|
||||
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
|
||||
participants: Object.keys(participants).length > 0 ? participants : undefined,
|
||||
recurrenceRules: recurrenceFromOption(recurrence),
|
||||
alerts: alertFromOption(alert),
|
||||
alerts: remindersToAlerts(reminders),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
locations: buildLocations(location),
|
||||
virtualLocations: buildVirtualLocations(videoUrl),
|
||||
};
|
||||
await onSave(data, calendarId);
|
||||
onClose();
|
||||
@@ -252,7 +260,21 @@ export function EventModal({
|
||||
|
||||
const selectedCalendar = calendars.find((c) => c.id === calendarId);
|
||||
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
|
||||
const alertLabel = ALERT_OPTIONS.find((o) => o.value === alert)?.label || 'None';
|
||||
|
||||
const addReminder = (minutesBefore: number) => {
|
||||
setReminderPickerOpen(false);
|
||||
setReminders((prev) =>
|
||||
prev.some((r) => r.minutesBefore === minutesBefore)
|
||||
? prev
|
||||
: [...prev, { minutesBefore }].sort((a, b) => b.minutesBefore - a.minutesBefore),
|
||||
);
|
||||
};
|
||||
const removeReminder = (minutesBefore: number) => {
|
||||
setReminders((prev) => prev.filter((r) => r.minutesBefore !== minutesBefore));
|
||||
};
|
||||
const availablePresets = REMINDER_PRESETS.filter(
|
||||
(p) => !reminders.some((r) => r.minutesBefore === p),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -264,7 +286,7 @@ export function EventModal({
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
|
||||
<X size={20} color={colors.text} />
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{isEdit ? 'Edit event' : 'New event'}</Text>
|
||||
<Button
|
||||
@@ -287,12 +309,12 @@ export function EventModal({
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Title"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.titleInput}
|
||||
/>
|
||||
|
||||
<Section
|
||||
icon={<CalendarIcon size={16} color={colors.textMuted} />}
|
||||
icon={<CalendarIcon size={16} color={c.textMuted} />}
|
||||
label="Calendar"
|
||||
>
|
||||
<Pressable
|
||||
@@ -340,14 +362,14 @@ export function EventModal({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<Clock size={16} color={colors.textMuted} />} label="Time">
|
||||
<Section icon={<Clock size={16} color={c.textMuted} />} label="Time">
|
||||
<View style={styles.allDayRow}>
|
||||
<Text style={styles.fieldText}>All-day</Text>
|
||||
<Switch
|
||||
value={allDay}
|
||||
onValueChange={setAllDay}
|
||||
thumbColor={allDay ? colors.primary : colors.textMuted}
|
||||
trackColor={{ false: colors.surface, true: colors.primaryBg }}
|
||||
thumbColor={allDay ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -392,7 +414,7 @@ export function EventModal({
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Repeat size={16} color={colors.textMuted} />} label="Repeat">
|
||||
<Section icon={<Repeat size={16} color={c.textMuted} />} label="Repeat">
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => setRecurrenceOpen((v) => !v)}
|
||||
@@ -420,50 +442,88 @@ export function EventModal({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<Bell size={16} color={colors.textMuted} />} label="Reminder">
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => setAlertOpen((v) => !v)}
|
||||
>
|
||||
<Text style={styles.fieldText}>{alertLabel}</Text>
|
||||
</Pressable>
|
||||
{alertOpen && (
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
|
||||
{reminders.length > 0 && (
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{reminders.map((r) => (
|
||||
<View key={r.minutesBefore} style={styles.reminderRow}>
|
||||
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
|
||||
<Pressable
|
||||
onPress={() => removeReminder(r.minutesBefore)}
|
||||
hitSlop={8}
|
||||
style={styles.reminderRemove}
|
||||
>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{availablePresets.length > 0 && (
|
||||
<Pressable
|
||||
style={styles.addRow}
|
||||
onPress={() => setReminderPickerOpen((v) => !v)}
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={styles.addRowText}>
|
||||
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{reminderPickerOpen && (
|
||||
<View style={styles.popover}>
|
||||
{ALERT_OPTIONS.map((opt) => (
|
||||
{availablePresets.map((preset) => (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
style={[
|
||||
styles.popoverRow,
|
||||
opt.value === alert && styles.popoverRowActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setAlert(opt.value);
|
||||
setAlertOpen(false);
|
||||
}}
|
||||
key={preset}
|
||||
style={styles.popoverRow}
|
||||
onPress={() => addReminder(preset)}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location">
|
||||
<TextInput
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="Place or address"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call">
|
||||
<TextInput
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
placeholder="https://meet.example.com/…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
icon={<MapPin size={16} color={colors.textMuted} />}
|
||||
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||
label="Description"
|
||||
>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Notes, location, link…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholder="Notes, agenda…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
multiline
|
||||
style={styles.descriptionInput}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
icon={<Users size={16} color={colors.textMuted} />}
|
||||
icon={<Users size={16} color={c.textMuted} />}
|
||||
label="Participants"
|
||||
>
|
||||
<ParticipantInput
|
||||
@@ -480,7 +540,7 @@ export function EventModal({
|
||||
]}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<Trash2 size={16} color={colors.error} />
|
||||
<Trash2 size={16} color={c.error} />
|
||||
<Text style={styles.deleteBtnText}>Delete event</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -560,6 +620,8 @@ function Section({
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
@@ -571,15 +633,16 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.md,
|
||||
},
|
||||
headerBtn: {
|
||||
@@ -589,23 +652,23 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerTitle: { flex: 1, ...typography.h3, color: colors.text },
|
||||
headerTitle: { flex: 1, ...typography.h3, color: c.text },
|
||||
|
||||
body: { flex: 1 },
|
||||
bodyContent: { padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xxxl },
|
||||
|
||||
titleInput: {
|
||||
...typography.h2,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
section: { gap: spacing.sm },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
sectionIcon: { width: 16 },
|
||||
sectionLabel: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
sectionLabel: { ...typography.bodyMedium, color: c.textSecondary },
|
||||
sectionBody: { gap: spacing.sm },
|
||||
|
||||
fieldButton: {
|
||||
@@ -614,20 +677,40 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fieldText: { ...typography.body, color: colors.text },
|
||||
fieldText: { ...typography.body, color: c.text },
|
||||
|
||||
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
|
||||
|
||||
reminderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
reminderRemove: { padding: 2 },
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
addRowText: { ...typography.body, color: c.primary },
|
||||
|
||||
popover: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
@@ -637,10 +720,10 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
popoverRowActive: { backgroundColor: colors.primaryBg },
|
||||
popoverRowText: { ...typography.body, color: colors.text },
|
||||
popoverRowActive: { backgroundColor: c.primaryBg },
|
||||
popoverRowText: { ...typography.body, color: c.text },
|
||||
|
||||
allDayRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -649,9 +732,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
dateRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -660,20 +743,20 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
dateLabel: { ...typography.body, color: colors.textSecondary, flex: 1 },
|
||||
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
||||
dateBtn: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
dateText: { ...typography.body, color: colors.text },
|
||||
dateText: { ...typography.body, color: c.text },
|
||||
|
||||
descriptionInput: {
|
||||
minHeight: 80,
|
||||
@@ -681,9 +764,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
@@ -696,9 +779,10 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
borderColor: c.errorBorder,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
deleteBtnPressed: { backgroundColor: colors.errorBg },
|
||||
deleteBtnText: { ...typography.bodyMedium, color: colors.error },
|
||||
});
|
||||
deleteBtnPressed: { backgroundColor: c.errorBg },
|
||||
deleteBtnText: { ...typography.bodyMedium, color: c.error },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { uploadBlob } from '../../api/blob';
|
||||
import { parseCalendarBlob } from '../../api/calendar';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||
type DocumentPickerModule = typeof import('expo-document-picker');
|
||||
let documentPickerModule: DocumentPickerModule | null = null;
|
||||
function loadDocumentPicker(): DocumentPickerModule | null {
|
||||
if (documentPickerModule) return documentPickerModule;
|
||||
try {
|
||||
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
|
||||
return documentPickerModule;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
calendars: Calendar[];
|
||||
/** Imports the selected events into the chosen calendar; returns count. */
|
||||
onImport: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
onImported?: (count: number) => void;
|
||||
}
|
||||
|
||||
function eventDateLabel(event: Partial<CalendarEvent>): string | null {
|
||||
if (!event.start) return null;
|
||||
const d = parseISO(event.start);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return event.showWithoutTime ? format(d, 'EEE, MMM d, yyyy') : format(d, 'EEE, MMM d · HH:mm');
|
||||
}
|
||||
|
||||
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const writable = React.useMemo(
|
||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
const [parsed, setParsed] = React.useState<Partial<CalendarEvent>[]>([]);
|
||||
const [selected, setSelected] = React.useState<Set<number>>(new Set());
|
||||
const [calendarId, setCalendarId] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [result, setResult] = React.useState<number | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const resetState = React.useCallback(() => {
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setImporting(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
resetState();
|
||||
if (writable[0]) setCalendarId(writable[0].id);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, slideY, overlayOpacity, resetState]);
|
||||
|
||||
const pickFile = async () => {
|
||||
const picker = loadDocumentPicker();
|
||||
if (!picker) {
|
||||
Alert.alert(
|
||||
'Import unavailable',
|
||||
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
let res;
|
||||
try {
|
||||
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not open the file picker');
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.assets.length === 0) return;
|
||||
const asset = res.assets[0];
|
||||
if (asset.size && asset.size > 5 * 1024 * 1024) {
|
||||
setError('That file is too large (max 5 MB).');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Upload the .ics so the server can parse it (CalendarEvent/parse).
|
||||
const { blobId } = await uploadBlob(asset.uri, 'text/calendar');
|
||||
const events = await parseCalendarBlob(blobId);
|
||||
const usable = events.filter((e) => !!e.start || e['@type'] === 'Task');
|
||||
if (usable.length === 0) {
|
||||
setError('No events were found in that file.');
|
||||
return;
|
||||
}
|
||||
setParsed(usable);
|
||||
setSelected(new Set(usable.map((_, i) => i)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
|
||||
const selectNone = () => setSelected(new Set());
|
||||
|
||||
const doImport = async () => {
|
||||
if (!calendarId) {
|
||||
Alert.alert('No calendar', 'Create a calendar before importing events.');
|
||||
return;
|
||||
}
|
||||
const toImport = parsed.filter((_, i) => selected.has(i));
|
||||
if (toImport.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const count = await onImport(toImport, calendarId);
|
||||
setResult(count);
|
||||
onImported?.(count);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Import calendar</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{result !== null ? (
|
||||
<View style={styles.center}>
|
||||
<View style={styles.successBadge}>
|
||||
<Check size={26} color={c.primaryForeground} />
|
||||
</View>
|
||||
<Text style={styles.successText}>
|
||||
Imported {result} event{result === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
|
||||
</View>
|
||||
) : parsed.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={c.primary} />
|
||||
) : (
|
||||
<Upload size={32} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.dropTitle}>{loading ? 'Parsing…' : 'Choose an .ics file'}</Text>
|
||||
<Text style={styles.dropHint}>iCalendar — single or multiple events</Text>
|
||||
</Pressable>
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{writable.length > 1 && (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.calChips}>
|
||||
{writable.map((cal) => (
|
||||
<Pressable
|
||||
key={cal.id}
|
||||
onPress={() => setCalendarId(cal.id)}
|
||||
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
|
||||
>
|
||||
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
|
||||
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.toolbarCount}>
|
||||
{parsed.length} found · {selected.size} selected
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
||||
<Pressable onPress={selectAll} hitSlop={6}><Text style={styles.link}>All</Text></Pressable>
|
||||
<Pressable onPress={selectNone} hitSlop={6}><Text style={styles.link}>None</Text></Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{parsed.map((event, idx) => {
|
||||
const isSelected = selected.has(idx);
|
||||
const date = eventDateLabel(event);
|
||||
return (
|
||||
<Pressable
|
||||
key={idx}
|
||||
onPress={() => toggle(idx)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
|
||||
{isSelected && <Check size={13} color={c.primaryForeground} />}
|
||||
</View>
|
||||
<Clock size={16} color={c.textMuted} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text>
|
||||
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
|
||||
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
|
||||
<Button
|
||||
onPress={() => { void doImport(); }}
|
||||
disabled={importing || selected.size === 0}
|
||||
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{importing ? 'Importing…' : `Import ${selected.size}`}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '85%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
|
||||
dropZone: {
|
||||
width: '100%',
|
||||
borderWidth: 2,
|
||||
borderColor: c.border,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: radius.lg,
|
||||
paddingVertical: spacing.xxxl,
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dropTitle: { ...typography.bodyMedium, color: c.text },
|
||||
dropHint: { ...typography.caption, color: c.textMuted },
|
||||
successBadge: {
|
||||
width: 56, height: 56, borderRadius: radius.full,
|
||||
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
successText: { ...typography.bodyMedium, color: c.text },
|
||||
calChips: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.sm },
|
||||
calChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
maxWidth: 160,
|
||||
},
|
||||
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||
calSwatch: { width: 10, height: 10, borderRadius: 5 },
|
||||
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
toolbarCount: { ...typography.caption, color: c.textMuted },
|
||||
link: { ...typography.captionMedium, color: c.primary },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 48,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
checkbox: {
|
||||
width: 20, height: 20, borderRadius: radius.xs,
|
||||
borderWidth: 1, borderColor: c.border,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowDate: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
|
||||
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
|
||||
const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription);
|
||||
const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription);
|
||||
const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription);
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setName('');
|
||||
setUrl('');
|
||||
setColor(CALENDAR_COLOR_PALETTE[0]);
|
||||
setFormError(null);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const n = name.trim();
|
||||
const u = url.trim();
|
||||
if (!n || !u) {
|
||||
setFormError('Enter a name and a feed URL.');
|
||||
return;
|
||||
}
|
||||
if (!/^(https?|webcal):\/\//i.test(u)) {
|
||||
setFormError('URL must start with https://, http:// or webcal://');
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
setAdding(true);
|
||||
try {
|
||||
const sub = await addSubscription({ name: n, url: u, color });
|
||||
if (sub.lastError) {
|
||||
setFormError(sub.lastError);
|
||||
} else {
|
||||
setName('');
|
||||
setUrl('');
|
||||
}
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Rss size={20} color={c.text} />
|
||||
<Text style={styles.title}>Calendar subscriptions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
|
||||
<Text style={styles.sectionLabel}>Add a feed</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Name (e.g. Holidays)"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
/>
|
||||
<TextInput
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://… or webcal://…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
style={styles.input}
|
||||
/>
|
||||
<View style={styles.colorRow}>
|
||||
{CALENDAR_COLOR_PALETTE.map((col) => (
|
||||
<Pressable
|
||||
key={col}
|
||||
onPress={() => setColor(col)}
|
||||
style={[
|
||||
styles.colorDot,
|
||||
{ backgroundColor: col },
|
||||
color === col && styles.colorDotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{!!formError && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{formError}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => { void handleAdd(); }}
|
||||
disabled={adding}
|
||||
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{adding ? 'Subscribing…' : 'Subscribe'}
|
||||
</Button>
|
||||
|
||||
{subscriptions.length > 0 && (
|
||||
<>
|
||||
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
|
||||
{subscriptions.map((sub) => {
|
||||
const busy = !!syncing[sub.id];
|
||||
return (
|
||||
<View key={sub.id} style={styles.subRow}>
|
||||
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
|
||||
<Text style={styles.subMeta} numberOfLines={1}>
|
||||
{sub.lastError
|
||||
? sub.lastError
|
||||
: sub.lastSyncAt
|
||||
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
|
||||
: 'Not synced yet'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => { void syncSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<RefreshCw size={16} color={c.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void removeSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={{ height: insets.bottom }} />
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '88%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { flex: 1, ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
scroll: { padding: spacing.lg, gap: spacing.sm },
|
||||
sectionLabel: { ...typography.bodySemibold, color: c.text, marginBottom: spacing.xs },
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
|
||||
colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' },
|
||||
colorDotActive: { borderColor: c.text },
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
subSwatch: { width: 14, height: 14, borderRadius: 7 },
|
||||
subName: { ...typography.body, color: c.text },
|
||||
subMeta: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
subBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
getWeek,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
@@ -12,7 +13,8 @@ import {
|
||||
startOfWeek,
|
||||
} from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { colors, componentSizes, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
@@ -20,7 +22,8 @@ import {
|
||||
type EventDayIndex,
|
||||
} from '../../lib/calendar-utils';
|
||||
|
||||
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
const SUN_FIRST = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
const MON_FIRST = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
|
||||
interface MonthViewProps {
|
||||
currentDate: Date;
|
||||
@@ -28,6 +31,8 @@ interface MonthViewProps {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
eventsByDay?: EventDayIndex;
|
||||
weekStartsOn?: 0 | 1;
|
||||
showWeekNumbers?: boolean;
|
||||
onSelectDate: (date: Date) => void;
|
||||
onLongPressDate?: (date: Date) => void;
|
||||
}
|
||||
@@ -38,9 +43,14 @@ function MonthViewInner({
|
||||
events,
|
||||
calendars,
|
||||
eventsByDay,
|
||||
weekStartsOn = 0,
|
||||
showWeekNumbers = false,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const weekdayLabels = weekStartsOn === 1 ? MON_FIRST : SUN_FIRST;
|
||||
const index = React.useMemo(
|
||||
() => eventsByDay ?? buildEventDayIndex(events),
|
||||
[eventsByDay, events],
|
||||
@@ -48,8 +58,8 @@ function MonthViewInner({
|
||||
const days = React.useMemo(() => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calStart = startOfWeek(monthStart);
|
||||
const calEnd = endOfWeek(monthEnd);
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
const result: Date[] = [];
|
||||
let day = calStart;
|
||||
while (day <= calEnd) {
|
||||
@@ -57,80 +67,115 @@ function MonthViewInner({
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
return result;
|
||||
}, [currentDate]);
|
||||
}, [currentDate, weekStartsOn]);
|
||||
|
||||
// Group days into rows of 7 so we can prepend a week-number column.
|
||||
const rows = React.useMemo(() => {
|
||||
const out: Date[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
|
||||
return out;
|
||||
}, [days]);
|
||||
|
||||
const renderDay = (d: Date, key: React.Key, flexLayout = false) => {
|
||||
const sameMonth = isSameMonth(d, currentDate);
|
||||
const today = isToday(d);
|
||||
const selected = isSameDay(d, selectedDate);
|
||||
const dayEvents = eventsOnDayFromIndex(index, d);
|
||||
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
|
||||
const visibleDots = dayEvents.slice(0, MAX_DOTS);
|
||||
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
style={[styles.dayCell, flexLayout && styles.dayCellFlex]}
|
||||
onPress={() => onSelectDate(d)}
|
||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||
>
|
||||
<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.dotsRow}>
|
||||
{visibleDots.map((event, idx) => (
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.grid}>
|
||||
<View style={styles.weekdayRow}>
|
||||
{WEEKDAYS.map((wd, i) => (
|
||||
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
|
||||
{weekdayLabels.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 = eventsOnDayFromIndex(index, d);
|
||||
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
|
||||
const visibleDots = dayEvents.slice(0, MAX_DOTS);
|
||||
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={i}
|
||||
style={styles.dayCell}
|
||||
onPress={() => onSelectDate(d)}
|
||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||
>
|
||||
<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.dotsRow}>
|
||||
{visibleDots.map((event, idx) => (
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{showWeekNumbers ? (
|
||||
rows.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={styles.weekRow}>
|
||||
<Text style={styles.weekNumberCell}>{getWeek(row[0], { weekStartsOn })}</Text>
|
||||
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.daysGrid}>
|
||||
{days.map((d, i) => renderDay(d, i))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const MonthView = React.memo(MonthViewInner);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
grid: { paddingHorizontal: spacing.sm },
|
||||
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
|
||||
weekdayLabel: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
weekRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
weekNumberLabel: {
|
||||
width: 24,
|
||||
textAlign: 'center',
|
||||
...typography.small,
|
||||
color: c.textMuted,
|
||||
},
|
||||
weekNumberCell: {
|
||||
width: 24,
|
||||
textAlign: 'center',
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
|
||||
dayCellFlex: { width: undefined, flex: 1 },
|
||||
dayNumber: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -138,12 +183,12 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dayText: { ...typography.body, color: colors.text },
|
||||
dayTextMuted: { color: colors.textMuted },
|
||||
todayCircle: { backgroundColor: colors.primary },
|
||||
todayText: { color: colors.textInverse, fontWeight: '700' },
|
||||
selectedCircle: { backgroundColor: colors.primaryBg, borderWidth: 1.5, borderColor: colors.primary },
|
||||
selectedText: { color: colors.primary, fontWeight: '600' },
|
||||
dayText: { ...typography.body, color: c.text },
|
||||
dayTextMuted: { color: c.textMuted },
|
||||
todayCircle: { backgroundColor: c.primary },
|
||||
todayText: { color: c.textInverse, fontWeight: '700' },
|
||||
selectedCircle: { backgroundColor: c.primaryBg, borderWidth: 1.5, borderColor: c.primary },
|
||||
selectedText: { color: c.primary, fontWeight: '600' },
|
||||
dotsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -160,9 +205,10 @@ const styles = StyleSheet.create({
|
||||
borderRadius: componentSizes.eventDot / 2,
|
||||
},
|
||||
overflowText: {
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
fontSize: 9,
|
||||
lineHeight: 10,
|
||||
marginLeft: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import type { Participant } from '../../api/types';
|
||||
|
||||
@@ -44,6 +45,8 @@ function flattenContactEmails(): Suggestion[] {
|
||||
}
|
||||
|
||||
export function ParticipantInput({ participants, onChange }: ParticipantInputProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [draft, setDraft] = React.useState('');
|
||||
const [allSuggestions] = React.useState(() => flattenContactEmails());
|
||||
|
||||
@@ -110,7 +113,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
||||
{p.name || p.email || 'Unknown'}
|
||||
</Text>
|
||||
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
|
||||
<X size={12} color={colors.textMuted} />
|
||||
<X size={12} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
@@ -121,7 +124,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Add participant by email"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
@@ -155,45 +158,47 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.sm },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 4,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
chipText: { ...typography.caption, color: colors.text, maxWidth: 200 },
|
||||
chipText: { ...typography.caption, color: c.text, maxWidth: 200 },
|
||||
input: {
|
||||
height: 40,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
suggestions: {
|
||||
maxHeight: 180,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
suggestionRow: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
suggestionRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
suggestionName: { ...typography.bodyMedium, color: colors.text },
|
||||
suggestionEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
||||
});
|
||||
suggestionRowPressed: { backgroundColor: c.surfaceHover },
|
||||
suggestionName: { ...typography.bodyMedium, color: c.text },
|
||||
suggestionEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { Repeat, Trash2 } from 'lucide-react-native';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button } from '..';
|
||||
|
||||
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
|
||||
@@ -25,6 +26,8 @@ export function RecurrenceScopeDialog({
|
||||
onSelect,
|
||||
onClose,
|
||||
}: RecurrenceScopeDialogProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [selected, setSelected] = React.useState<RecurrenceEditScope>('this');
|
||||
const isDelete = actionType === 'delete';
|
||||
|
||||
@@ -39,9 +42,9 @@ export function RecurrenceScopeDialog({
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconBadge, isDelete ? styles.iconBadgeDanger : styles.iconBadgePrimary]}>
|
||||
{isDelete ? (
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
<Trash2 size={20} color={c.error} />
|
||||
) : (
|
||||
<Repeat size={20} color={colors.primary} />
|
||||
<Repeat size={20} color={c.primary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerText}>
|
||||
@@ -90,7 +93,8 @@ export function RecurrenceScopeDialog({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
@@ -101,9 +105,9 @@ const styles = StyleSheet.create({
|
||||
dialog: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.xxl,
|
||||
gap: spacing.lg,
|
||||
@@ -116,11 +120,11 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconBadgeDanger: { backgroundColor: colors.errorBg },
|
||||
iconBadgePrimary: { backgroundColor: colors.primaryBg },
|
||||
iconBadgeDanger: { backgroundColor: c.errorBg },
|
||||
iconBadgePrimary: { backgroundColor: c.primaryBg },
|
||||
headerText: { flex: 1 },
|
||||
title: { ...typography.h3, color: colors.text },
|
||||
description: { ...typography.body, color: colors.textMuted, marginTop: 4 },
|
||||
title: { ...typography.h3, color: c.text },
|
||||
description: { ...typography.body, color: c.textMuted, marginTop: 4 },
|
||||
options: { gap: spacing.xs },
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
@@ -132,27 +136,28 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
optionActive: { backgroundColor: colors.primaryBg, borderColor: colors.primary },
|
||||
optionActive: { backgroundColor: c.primaryBg, borderColor: c.primary },
|
||||
radio: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
radioActive: { borderColor: colors.primary },
|
||||
radioActive: { borderColor: c.primary },
|
||||
radioDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
optionLabel: { ...typography.body, color: colors.text, flex: 1 },
|
||||
optionLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.md,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Check,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
interface TasksSheetProps {
|
||||
visible: boolean;
|
||||
tasks: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onClose: () => void;
|
||||
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
|
||||
onToggle: (id: string) => Promise<void> | void;
|
||||
onDelete: (id: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function isCompleted(task: CalendarEvent): boolean {
|
||||
return task.progress === 'completed';
|
||||
}
|
||||
|
||||
function dueLabel(task: CalendarEvent): string | null {
|
||||
if (!task.due) return null;
|
||||
const d = parseISO(task.due);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return format(d, 'EEE, MMM d');
|
||||
}
|
||||
|
||||
// Tasks first by completion (open first), then by due date (soonest first,
|
||||
// undated last), then title.
|
||||
function compareTasks(a: CalendarEvent, b: CalendarEvent): number {
|
||||
const ac = isCompleted(a);
|
||||
const bc = isCompleted(b);
|
||||
if (ac !== bc) return ac ? 1 : -1;
|
||||
const ad = a.due ? parseISO(a.due).getTime() : Infinity;
|
||||
const bd = b.due ? parseISO(b.due).getTime() : Infinity;
|
||||
if (ad !== bd) return ad - bd;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
}
|
||||
|
||||
export function TasksSheet({
|
||||
visible,
|
||||
tasks,
|
||||
calendars,
|
||||
onClose,
|
||||
onCreate,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: TasksSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({
|
||||
slideY,
|
||||
closedY: Dimensions.get('window').height,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const writableCalendars = React.useMemo(
|
||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [calendarId, setCalendarId] = React.useState('');
|
||||
const [due, setDue] = React.useState<Date | null>(null);
|
||||
const [showDuePicker, setShowDuePicker] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible && !calendarId && writableCalendars[0]) {
|
||||
setCalendarId(writableCalendars[0].id);
|
||||
}
|
||||
}, [visible, calendarId, writableCalendars]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: Dimensions.get('window').height, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const sorted = React.useMemo(() => [...tasks].sort(compareTasks), [tasks]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const t = title.trim();
|
||||
if (!t || !calendarId || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const task: Partial<CalendarEvent> = {
|
||||
title: t,
|
||||
progress: 'needs-action',
|
||||
};
|
||||
if (due) {
|
||||
task.due = format(due, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
}
|
||||
await onCreate(task, calendarId);
|
||||
setTitle('');
|
||||
setDue(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<ListChecks size={20} color={c.text} />
|
||||
<Text style={styles.headerTitle}>Tasks</Text>
|
||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<X size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.addRow}>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Add a task…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.addInput}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void handleAdd(); }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => setShowDuePicker(true)}
|
||||
style={[styles.dueChip, due && styles.dueChipActive]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<CalendarIcon size={14} color={due ? c.primary : c.textMuted} />
|
||||
{due && <Text style={styles.dueChipText}>{format(due, 'MMM d')}</Text>}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void handleAdd(); }}
|
||||
disabled={!title.trim() || !calendarId || saving}
|
||||
style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]}
|
||||
>
|
||||
<Plus size={18} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{writableCalendars.length > 1 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.calChips}
|
||||
>
|
||||
{writableCalendars.map((cal) => (
|
||||
<Pressable
|
||||
key={cal.id}
|
||||
onPress={() => setCalendarId(cal.id)}
|
||||
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
|
||||
>
|
||||
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
|
||||
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
|
||||
{sorted.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<ListChecks size={32} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyText}>No tasks yet</Text>
|
||||
</View>
|
||||
) : (
|
||||
sorted.map((task) => {
|
||||
const completed = isCompleted(task);
|
||||
const due = dueLabel(task);
|
||||
return (
|
||||
<View key={task.id} style={styles.taskRow}>
|
||||
<Pressable
|
||||
onPress={() => { void onToggle(task.id); }}
|
||||
style={[styles.checkbox, completed && styles.checkboxChecked]}
|
||||
hitSlop={8}
|
||||
>
|
||||
{completed && <Check size={14} color={c.primaryForeground} />}
|
||||
</Pressable>
|
||||
<View style={styles.taskText}>
|
||||
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
|
||||
{task.title || 'Untitled task'}
|
||||
</Text>
|
||||
{due && <Text style={styles.taskDue}>{due}</Text>}
|
||||
</View>
|
||||
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
|
||||
<Trash2 size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
|
||||
{showDuePicker && (
|
||||
<DateTimePicker
|
||||
value={due ?? new Date()}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={(_, d) => {
|
||||
setShowDuePicker(false);
|
||||
if (d) setDue(d);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '85%',
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.surfaceActive },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
headerTitle: { flex: 1, ...typography.h3, color: c.text },
|
||||
closeBtn: { padding: 4 },
|
||||
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
addInput: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
dueChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
height: 38,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
dueChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||
dueChipText: { ...typography.caption, color: c.primary },
|
||||
addBtn: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
addBtnDisabled: { opacity: 0.5 },
|
||||
|
||||
calChips: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm },
|
||||
calChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
maxWidth: 160,
|
||||
},
|
||||
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||
calSwatch: { width: 10, height: 10, borderRadius: 5 },
|
||||
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||||
|
||||
list: { flexGrow: 0 },
|
||||
listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
|
||||
empty: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.sm },
|
||||
emptyText: { ...typography.body, color: c.textMuted },
|
||||
|
||||
taskRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 2,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkboxChecked: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
taskText: { flex: 1 },
|
||||
taskTitle: { ...typography.body, color: c.text },
|
||||
taskTitleDone: { textDecorationLine: 'line-through', color: c.textMuted },
|
||||
taskDue: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
taskDelete: { padding: 4 },
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from 'react-native';
|
||||
import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
buildTimedFullDayWeekSegments,
|
||||
@@ -37,6 +38,7 @@ interface WeekViewProps {
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
weekStartsOn?: 0 | 1;
|
||||
timeFormat?: '12h' | '24h';
|
||||
}
|
||||
|
||||
function WeekViewInner({
|
||||
@@ -48,7 +50,10 @@ function WeekViewInner({
|
||||
onSelectEvent,
|
||||
onCreateAtTime,
|
||||
weekStartsOn = 0,
|
||||
timeFormat = '24h',
|
||||
}: WeekViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const scrollRef = React.useRef<ScrollView>(null);
|
||||
|
||||
const index = React.useMemo(
|
||||
@@ -221,7 +226,9 @@ function WeekViewInner({
|
||||
<View key={h} style={[styles.hourLabelCell, { height: HOUR_HEIGHT }]}>
|
||||
{h > 0 && (
|
||||
<Text style={styles.hourLabel}>
|
||||
{h.toString().padStart(2, '0')}:00
|
||||
{timeFormat === '12h'
|
||||
? `${((h % 12) || 12)} ${h < 12 ? 'AM' : 'PM'}`
|
||||
: `${h.toString().padStart(2, '0')}:00`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -311,12 +318,13 @@ function minutesToTimeLabel(minutes: number): string {
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
gutter: { width: GUTTER_WIDTH },
|
||||
dayHeaders: { flex: 1, flexDirection: 'row' },
|
||||
@@ -328,7 +336,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
dayHeaderWeekday: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
dayHeaderNumber: {
|
||||
@@ -338,29 +346,29 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dayHeaderNumberToday: { backgroundColor: colors.primary },
|
||||
dayHeaderNumberToday: { backgroundColor: c.primary },
|
||||
dayHeaderNumberSelected: {
|
||||
backgroundColor: colors.primaryBg,
|
||||
backgroundColor: c.primaryBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderColor: c.primary,
|
||||
},
|
||||
dayHeaderNumberText: { ...typography.bodyMedium, color: colors.text },
|
||||
dayHeaderNumberTextToday: { color: colors.textInverse, fontWeight: '700' },
|
||||
dayHeaderNumberTextSelected: { color: colors.primary, fontWeight: '600' },
|
||||
dayHeaderNumberText: { ...typography.bodyMedium, color: c.text },
|
||||
dayHeaderNumberTextToday: { color: c.textInverse, fontWeight: '700' },
|
||||
dayHeaderNumberTextSelected: { color: c.primary, fontWeight: '600' },
|
||||
|
||||
allDayStrip: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4 },
|
||||
allDayLabel: { ...typography.small, color: colors.textMuted },
|
||||
allDayLabel: { ...typography.small, color: c.textMuted },
|
||||
allDayGrid: { flex: 1, flexDirection: 'row', position: 'relative' },
|
||||
allDayCol: {
|
||||
flex: 1,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: colors.borderLight,
|
||||
borderLeftColor: c.borderLight,
|
||||
},
|
||||
allDayOverlay: {
|
||||
position: 'absolute',
|
||||
@@ -376,7 +384,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 4,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
allDayChipText: { fontSize: 10, color: colors.text, fontWeight: '500' },
|
||||
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
|
||||
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: {},
|
||||
@@ -385,19 +393,19 @@ const styles = StyleSheet.create({
|
||||
hourLabelCell: { paddingRight: 4, alignItems: 'flex-end' },
|
||||
hourLabel: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
transform: [{ translateY: -6 }],
|
||||
},
|
||||
dayCols: { flex: 1, flexDirection: 'row' },
|
||||
dayCol: {
|
||||
flex: 1,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: colors.border,
|
||||
borderLeftColor: c.border,
|
||||
position: 'relative',
|
||||
},
|
||||
hourSlot: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
eventBlock: {
|
||||
position: 'absolute',
|
||||
@@ -407,8 +415,8 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
eventBlockTitle: { fontSize: 10, color: colors.text, fontWeight: '600' },
|
||||
eventBlockTime: { fontSize: 9, color: colors.textMuted, marginTop: 1 },
|
||||
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
|
||||
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
|
||||
|
||||
nowLine: {
|
||||
position: 'absolute',
|
||||
@@ -421,7 +429,8 @@ const styles = StyleSheet.create({
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.error,
|
||||
backgroundColor: c.error,
|
||||
},
|
||||
nowBar: { flex: 1, height: 1, backgroundColor: colors.error },
|
||||
});
|
||||
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { BookUser, X, Check, Plus } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Book the contact(s) currently live in - shown with a check, not selectable. */
|
||||
currentBookId?: string | null;
|
||||
onPick: (addressBookId: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function AddressBookPickerSheet({
|
||||
visible, onClose, currentBookId, onPick, title = 'Move to address book',
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const createAddressBook = useContactsStore((s) => s.createAddressBook);
|
||||
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [newName, setNewName] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(600)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const book = await createAddressBook(name);
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
onPick(book.id);
|
||||
} catch (err) {
|
||||
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
{addressBooks.map((book) => {
|
||||
const isCurrent = book.id === currentBookId;
|
||||
const canWrite = book.myRights?.mayWrite !== false;
|
||||
const canTarget = canWrite && !isCurrent;
|
||||
return (
|
||||
<Pressable
|
||||
key={book.id}
|
||||
onPress={canTarget ? () => onPick(book.id) : undefined}
|
||||
disabled={!canTarget}
|
||||
style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]}
|
||||
>
|
||||
<BookUser size={16} color={canTarget ? c.textSecondary : c.textMuted} />
|
||||
<Text style={[styles.rowLabel, !canTarget && styles.rowLabelDisabled]} numberOfLines={1}>
|
||||
{book.name}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{creating ? (
|
||||
<View style={styles.createRow}>
|
||||
<TextInput
|
||||
style={styles.createInput}
|
||||
placeholder="New address book name"
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void handleCreate(); }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => { void handleCreate(); }}
|
||||
disabled={!newName.trim() || busy}
|
||||
style={[styles.createBtn, (!newName.trim() || busy) && styles.createBtnDisabled]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Check size={16} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => setCreating(true)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={[styles.rowLabel, { color: c.primary }]}>New address book…</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
rowLabelDisabled: { color: c.textMuted },
|
||||
createRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
createInput: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
height: componentSizes.inputHeight,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
createBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
createBtnDisabled: { opacity: 0.4 },
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import type { RootStackParamList } from '../../navigation/types';
|
||||
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
const EVENT_LIMIT = 5;
|
||||
@@ -82,6 +83,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ContactActivity({ contact }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const calendarEvents = useCalendarStore((s) => s.events);
|
||||
|
||||
@@ -142,12 +145,12 @@ export function ContactActivity({ contact }: Props) {
|
||||
<View style={styles.wrap}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MailIcon size={14} color={colors.primary} />
|
||||
<MailIcon size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Recent emails</Text>
|
||||
</View>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
@@ -190,7 +193,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<CalendarDays size={14} color={colors.primary} />
|
||||
<CalendarDays size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Upcoming events</Text>
|
||||
</View>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
@@ -215,21 +218,22 @@ export function ContactActivity({ contact }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
|
||||
section: {
|
||||
backgroundColor: colors.card,
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary,
|
||||
borderLeftColor: c.primary,
|
||||
},
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||
sectionTitle: { ...typography.bodySemibold, color: colors.text },
|
||||
sectionTitle: { ...typography.bodySemibold, color: c.text },
|
||||
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
|
||||
emptyText: { ...typography.caption, color: colors.textMuted },
|
||||
emptyText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
@@ -238,21 +242,22 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
rowPressed: { backgroundColor: colors.surface },
|
||||
rowPressed: { backgroundColor: c.surface },
|
||||
rowContent: { flex: 1, minWidth: 0 },
|
||||
rowTop: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm },
|
||||
rowSender: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
||||
rowDate: { ...typography.caption, color: colors.textMuted },
|
||||
rowSubject: { ...typography.body, color: colors.text },
|
||||
rowPreview: { ...typography.caption, color: colors.textMuted },
|
||||
rowSender: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
rowDate: { ...typography.caption, color: c.textMuted },
|
||||
rowSubject: { ...typography.body, color: c.text },
|
||||
rowPreview: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
|
||||
eventTime: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
width: 64,
|
||||
},
|
||||
eventInfo: { flex: 1, minWidth: 0 },
|
||||
eventTitle: { ...typography.body, color: colors.text },
|
||||
eventDate: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
});
|
||||
eventTitle: { ...typography.body, color: c.text },
|
||||
eventDate: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
import { parseVCard, detectDuplicates } from '../../lib/vcard';
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen upload flow).
|
||||
type DocumentPickerModule = typeof import('expo-document-picker');
|
||||
let documentPickerModule: DocumentPickerModule | null = null;
|
||||
function loadDocumentPicker(): DocumentPickerModule | null {
|
||||
if (documentPickerModule) return documentPickerModule;
|
||||
try {
|
||||
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
|
||||
return documentPickerModule;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Address book the imported contacts are created in. */
|
||||
targetBookId: string | null;
|
||||
targetBookName?: string;
|
||||
onChangeTarget?: () => void;
|
||||
onImported?: (count: number) => void;
|
||||
}
|
||||
|
||||
export default function ContactImportSheet({
|
||||
visible, onClose, targetBookId, targetBookName, onChangeTarget, onImported,
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const existing = useContactsStore((s) => s.contacts);
|
||||
const importContacts = useContactsStore((s) => s.importContacts);
|
||||
|
||||
const [parsed, setParsed] = React.useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = React.useState<Set<number>>(new Set());
|
||||
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [result, setResult] = React.useState<number | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const resetState = React.useCallback(() => {
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setDuplicates(new Map());
|
||||
setError(null);
|
||||
setImporting(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
resetState();
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity, resetState]);
|
||||
|
||||
const pickFile = async () => {
|
||||
const picker = loadDocumentPicker();
|
||||
if (!picker) {
|
||||
Alert.alert(
|
||||
'Import unavailable',
|
||||
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
let res;
|
||||
try {
|
||||
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not open the file picker');
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.assets.length === 0) return;
|
||||
const asset = res.assets[0];
|
||||
|
||||
if (asset.size && asset.size > 5 * 1024 * 1024) {
|
||||
setError('That file is too large (max 5 MB).');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await FileSystem.readAsStringAsync(asset.uri);
|
||||
const contacts = parseVCard(text);
|
||||
if (contacts.length === 0) {
|
||||
setError('No contacts were found in that file.');
|
||||
return;
|
||||
}
|
||||
const dupes = detectDuplicates(existing, contacts);
|
||||
const initial = new Set<number>();
|
||||
contacts.forEach((_, idx) => { if (!dupes.has(idx)) initial.add(idx); });
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
setSelected(initial);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
|
||||
const selectNone = () => setSelected(new Set());
|
||||
|
||||
const doImport = async () => {
|
||||
if (!targetBookId) {
|
||||
Alert.alert('No address book', 'Create an address book before importing contacts.');
|
||||
return;
|
||||
}
|
||||
const toImport = parsed.filter((_, i) => selected.has(i));
|
||||
if (toImport.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const count = await importContacts(toImport, targetBookId);
|
||||
setResult(count);
|
||||
onImported?.(count);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Import contacts</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{result !== null ? (
|
||||
<View style={styles.center}>
|
||||
<View style={styles.successBadge}>
|
||||
<Check size={26} color={c.primaryForeground} />
|
||||
</View>
|
||||
<Text style={styles.successText}>
|
||||
Imported {result} contact{result === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
|
||||
</View>
|
||||
) : parsed.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }}>
|
||||
<Upload size={32} color={c.textMuted} />
|
||||
<Text style={styles.dropTitle}>Choose a vCard file</Text>
|
||||
<Text style={styles.dropHint}>.vcf / .vcard — single or multiple contacts</Text>
|
||||
</Pressable>
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Pressable
|
||||
style={styles.targetRow}
|
||||
onPress={onChangeTarget}
|
||||
disabled={!onChangeTarget}
|
||||
>
|
||||
<BookUser size={16} color={c.textSecondary} />
|
||||
<Text style={styles.targetText} numberOfLines={1}>
|
||||
Import into {targetBookName || 'address book'}
|
||||
</Text>
|
||||
{onChangeTarget && <Text style={styles.targetChange}>Change</Text>}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.toolbarCount}>
|
||||
{parsed.length} found · {selected.size} selected
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
||||
<Pressable onPress={selectAll} hitSlop={6}>
|
||||
<Text style={styles.link}>All</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={selectNone} hitSlop={6}>
|
||||
<Text style={styles.link}>None</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{parsed.map((contact, idx) => {
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const isDupe = duplicates.has(idx);
|
||||
const isSelected = selected.has(idx);
|
||||
return (
|
||||
<Pressable
|
||||
key={idx}
|
||||
onPress={() => toggle(idx)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
|
||||
{isSelected && <Check size={13} color={c.primaryForeground} />}
|
||||
</View>
|
||||
<FileText size={16} color={c.textMuted} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{name || email || '—'}</Text>
|
||||
{!!email && !!name && (
|
||||
<Text style={styles.rowEmail} numberOfLines={1}>{email}</Text>
|
||||
)}
|
||||
</View>
|
||||
{isDupe && (
|
||||
<Text style={styles.dupeBadge}>Duplicate</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
|
||||
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
|
||||
<Button
|
||||
onPress={() => { void doImport(); }}
|
||||
disabled={importing || selected.size === 0}
|
||||
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
|
||||
>
|
||||
{importing ? 'Importing…' : `Import ${selected.size}`}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '85%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
|
||||
dropZone: {
|
||||
width: '100%',
|
||||
borderWidth: 2,
|
||||
borderColor: c.border,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: radius.lg,
|
||||
paddingVertical: spacing.xxxl,
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dropTitle: { ...typography.bodyMedium, color: c.text },
|
||||
dropHint: { ...typography.caption, color: c.textMuted },
|
||||
successBadge: {
|
||||
width: 56, height: 56, borderRadius: radius.full,
|
||||
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
successText: { ...typography.bodyMedium, color: c.text },
|
||||
targetRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
targetText: { ...typography.caption, color: c.textSecondary, flex: 1 },
|
||||
targetChange: { ...typography.captionMedium, color: c.primary },
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
toolbarCount: { ...typography.caption, color: c.textMuted },
|
||||
link: { ...typography.captionMedium, color: c.primary },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 48,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
checkbox: {
|
||||
width: 20, height: 20, borderRadius: radius.xs,
|
||||
borderWidth: 1, borderColor: c.border,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
dupeBadge: {
|
||||
...typography.small,
|
||||
color: c.warning,
|
||||
backgroundColor: c.warningBg,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
isGroup,
|
||||
} from '../../lib/contact-utils';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
interface ContactListRowProps {
|
||||
contact: ContactCard;
|
||||
@@ -29,6 +30,8 @@ export default function ContactListRow({
|
||||
selectionMode,
|
||||
secondaryText,
|
||||
}: ContactListRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const name = getContactDisplayName(contact) || 'Unnamed';
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const org = getPrimaryOrg(contact);
|
||||
@@ -50,7 +53,7 @@ export default function ContactListRow({
|
||||
>
|
||||
{selectionMode && (
|
||||
<View style={[styles.checkbox, selected && styles.checkboxSelected]}>
|
||||
{selected && <Check size={14} color={colors.primaryForeground} />}
|
||||
{selected && <Check size={14} color={c.primaryForeground} />}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -58,7 +61,7 @@ export default function ContactListRow({
|
||||
<Image source={{ uri: photoUri }} style={styles.avatarImage} />
|
||||
) : group ? (
|
||||
<View style={styles.groupAvatar}>
|
||||
<Users size={18} color={colors.primary} />
|
||||
<Users size={18} color={c.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<SenderAvatar name={name} email={email} size={componentSizes.avatarMd} />
|
||||
@@ -77,7 +80,8 @@ export default function ContactListRow({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -85,37 +89,38 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
||||
rowSelected: { backgroundColor: colors.primaryBg },
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowSelected: { backgroundColor: c.primaryBg },
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
borderColor: c.primary,
|
||||
},
|
||||
avatarImage: {
|
||||
width: componentSizes.avatarMd,
|
||||
height: componentSizes.avatarMd,
|
||||
borderRadius: componentSizes.avatarMd / 2,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
groupAvatar: {
|
||||
width: componentSizes.avatarMd,
|
||||
height: componentSizes.avatarMd,
|
||||
borderRadius: componentSizes.avatarMd / 2,
|
||||
backgroundColor: colors.primaryBg,
|
||||
backgroundColor: c.primaryBg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
info: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.bodyMedium, color: colors.text },
|
||||
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
||||
tertiary: { ...typography.small, color: colors.textSecondary, marginTop: 1 },
|
||||
});
|
||||
name: { ...typography.bodyMedium, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
} from '../../stores/contacts-store';
|
||||
import { matchesContactSearch, isGroup } from '../../lib/contact-utils';
|
||||
import ContactListRow from './ContactListRow';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -26,9 +28,11 @@ interface Props {
|
||||
export default function ContactPickerSheet({
|
||||
visible, onClose, onSelect, title = 'Add Contact', excludedIds, multi = true,
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const allContacts = useContactsStore((s) => s.contacts);
|
||||
const sorted = React.useMemo(
|
||||
() => sortContactsByDisplayName(allContacts.filter((c) => !isGroup(c))),
|
||||
() => sortContactsByDisplayName(allContacts.filter((contact) => !isGroup(contact))),
|
||||
[allContacts],
|
||||
);
|
||||
const [query, setQuery] = React.useState('');
|
||||
@@ -36,6 +40,11 @@ export default function ContactPickerSheet({
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||
const overlay = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({
|
||||
slideY,
|
||||
closedY: Dimensions.get('window').height,
|
||||
onClose,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -90,37 +99,41 @@ export default function ContactPickerSheet({
|
||||
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
|
||||
<View style={styles.handle} />
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
|
||||
<X size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
{multi && (
|
||||
<Pressable
|
||||
onPress={confirm}
|
||||
disabled={selected.size === 0}
|
||||
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
{multi && (
|
||||
<Pressable
|
||||
onPress={confirm}
|
||||
disabled={selected.size === 0}
|
||||
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<Search size={16} color={c.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search contacts..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<Pressable onPress={() => setQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -150,24 +163,28 @@ export default function ContactPickerSheet({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, bottom: 0,
|
||||
top: '15%',
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
},
|
||||
handleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
handle: {
|
||||
alignSelf: 'center',
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
marginTop: spacing.sm,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
@@ -176,23 +193,23 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 40, height: 40,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
|
||||
doneBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 36,
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
doneBtnDisabled: { opacity: 0.5 },
|
||||
doneLabel: { ...typography.bodyMedium, color: colors.primaryForeground },
|
||||
doneLabel: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
@@ -202,14 +219,15 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
|
||||
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||
|
||||
empty: { paddingVertical: spacing.xxxl, alignItems: 'center' },
|
||||
emptyText: { ...typography.body, color: colors.textMuted },
|
||||
});
|
||||
emptyText: { ...typography.body, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import type { ContactCategory } from '../../stores/contacts-store';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -25,6 +26,8 @@ function isSameCategory(a: ContactCategory, b: ContactCategory): boolean {
|
||||
}
|
||||
|
||||
export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
@@ -93,14 +96,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||
<X size={20} color={colors.text} />
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>Contacts</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||
<CategoryRow
|
||||
icon={<Inbox size={16} color={colors.primary} />}
|
||||
icon={<Inbox size={16} color={c.primary} />}
|
||||
label="All Contacts"
|
||||
count={totalCount}
|
||||
active={selectedCategory.type === 'all'}
|
||||
@@ -115,7 +118,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
{expanded.books && books.map((book) => (
|
||||
<CategoryRow
|
||||
key={book.id}
|
||||
icon={<BookUser size={16} color={colors.textSecondary} />}
|
||||
icon={<BookUser size={16} color={c.textSecondary} />}
|
||||
label={book.name}
|
||||
count={book.count}
|
||||
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
|
||||
@@ -124,7 +127,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
))}
|
||||
{expanded.books && (
|
||||
<CategoryRow
|
||||
icon={<BookUser size={16} color={colors.textMuted} />}
|
||||
icon={<BookUser size={16} color={c.textMuted} />}
|
||||
label="Uncategorized"
|
||||
count={0}
|
||||
active={selectedCategory.type === 'uncategorized'}
|
||||
@@ -142,7 +145,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
{expanded.groups && groups.map((g) => (
|
||||
<CategoryRow
|
||||
key={g.id}
|
||||
icon={<Users size={16} color={colors.textSecondary} />}
|
||||
icon={<Users size={16} color={c.textSecondary} />}
|
||||
label={getContactDisplayName(g) || 'Group'}
|
||||
count={g.members ? Object.keys(g.members).length : 0}
|
||||
active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })}
|
||||
@@ -160,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
{expanded.tags && keywords.map((kw) => (
|
||||
<CategoryRow
|
||||
key={kw.keyword}
|
||||
icon={<Tag size={16} color={colors.textSecondary} />}
|
||||
icon={<Tag size={16} color={c.textSecondary} />}
|
||||
label={kw.keyword}
|
||||
count={kw.count}
|
||||
active={isSameCategory(selectedCategory, { type: 'keyword', keyword: kw.keyword })}
|
||||
@@ -175,12 +178,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
}
|
||||
|
||||
function SectionHeader({ label, expanded, onPress }: { label: string; expanded: boolean; onPress: () => void }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable style={styles.sectionHeader} onPress={onPress}>
|
||||
{expanded ? (
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
<ChevronDown size={14} color={c.textMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={colors.textMuted} />
|
||||
<ChevronRight size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.sectionHeaderText}>{label}</Text>
|
||||
</Pressable>
|
||||
@@ -196,6 +201,8 @@ function CategoryRow({
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -212,7 +219,8 @@ function CategoryRow({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
drawer: {
|
||||
@@ -220,9 +228,9 @@ const styles = StyleSheet.create({
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.border,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
drawerSafe: { flex: 1 },
|
||||
|
||||
@@ -233,14 +241,14 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
headerClose: {
|
||||
width: 36, height: 36,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text },
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: { paddingBottom: spacing.md },
|
||||
@@ -253,7 +261,7 @@ const styles = StyleSheet.create({
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
sectionHeaderText: { ...typography.bodySemibold, color: colors.text },
|
||||
sectionHeaderText: { ...typography.bodySemibold, color: c.text },
|
||||
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
@@ -263,14 +271,15 @@ const styles = StyleSheet.create({
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: 'transparent',
|
||||
},
|
||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
||||
rowActive: { backgroundColor: colors.accent, borderLeftColor: colors.primary },
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
|
||||
rowIcon: {
|
||||
width: 20, height: 20,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rowLabel: { flex: 1, ...typography.body, color: colors.text },
|
||||
rowLabelActive: { ...typography.bodySemibold, color: colors.text },
|
||||
rowCount: { ...typography.caption, color: colors.textMuted, marginLeft: spacing.sm },
|
||||
});
|
||||
rowLabel: { flex: 1, ...typography.body, color: c.text },
|
||||
rowLabelActive: { ...typography.bodySemibold, color: c.text },
|
||||
rowCount: { ...typography.caption, color: c.textMuted, marginLeft: spacing.sm },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, X } from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
export type CategoryAccent = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'calendar' | 'notes';
|
||||
|
||||
const ACCENT_COLORS: Record<CategoryAccent, string> = {
|
||||
contact: colors.primary,
|
||||
work: colors.calendar.orange,
|
||||
location: colors.calendar.green,
|
||||
personal: colors.calendar.pink,
|
||||
digital: colors.calendar.teal,
|
||||
calendar: colors.calendar.indigo,
|
||||
notes: colors.calendar.purple,
|
||||
};
|
||||
function accentColor(category: CategoryAccent, c: ThemePalette): string {
|
||||
switch (category) {
|
||||
case 'contact': return c.primary;
|
||||
case 'work': return c.calendar.orange;
|
||||
case 'location': return c.calendar.green;
|
||||
case 'personal': return c.calendar.pink;
|
||||
case 'digital': return c.calendar.teal;
|
||||
case 'calendar': return c.calendar.indigo;
|
||||
case 'notes': return c.calendar.purple;
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldBlockProps {
|
||||
title: string;
|
||||
@@ -27,7 +30,9 @@ interface FieldBlockProps {
|
||||
export default function FieldBlock({
|
||||
title, category = 'contact', icon, onAdd, addLabel, children,
|
||||
}: FieldBlockProps) {
|
||||
const accent = ACCENT_COLORS[category];
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const accent = accentColor(category, c);
|
||||
return (
|
||||
<View style={[styles.block, { borderLeftColor: accent }]}>
|
||||
<View style={styles.header}>
|
||||
@@ -36,7 +41,7 @@ export default function FieldBlock({
|
||||
<View style={{ flex: 1 }} />
|
||||
{onAdd && (
|
||||
<Pressable onPress={onAdd} hitSlop={8} style={styles.addBtn}>
|
||||
<Plus size={14} color={colors.primary} />
|
||||
<Plus size={14} color={c.primary} />
|
||||
{!!addLabel && <Text style={styles.addLabel}>{addLabel}</Text>}
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -52,59 +57,63 @@ interface FieldRowProps {
|
||||
}
|
||||
|
||||
export function FieldRow({ onRemove, children }: FieldRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1 }}>{children}</View>
|
||||
{onRemove && (
|
||||
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
|
||||
<X size={14} color={colors.textMuted} />
|
||||
<X size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
block: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: colors.text },
|
||||
addBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primaryBg,
|
||||
},
|
||||
addLabel: { ...typography.caption, color: colors.primary },
|
||||
body: { gap: spacing.sm },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
removeBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surface,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
block: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.card,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
addBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
addLabel: { ...typography.caption, color: c.primary },
|
||||
body: { gap: spacing.sm },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
removeBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.surface,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Tag, X, Plus } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the chosen tag (existing or freshly typed). */
|
||||
onPick: (keyword: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function TagAssignSheet({
|
||||
visible, onClose, onPick, title = 'Add tag',
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const keywords = useContactsStore(selectKeywordsUsed);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(600)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setInput('');
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const submitNew = () => {
|
||||
const kw = input.trim();
|
||||
if (!kw) return;
|
||||
onPick(kw);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="New tag name"
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={submitNew}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={submitNew}
|
||||
disabled={!input.trim()}
|
||||
style={[styles.addBtn, !input.trim() && styles.addBtnDisabled]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Plus size={16} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={styles.empty}>No tags yet. Type one above.</Text>
|
||||
) : (
|
||||
keywords.map((kw) => (
|
||||
<Pressable
|
||||
key={kw.keyword}
|
||||
onPress={() => onPick(kw.keyword)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Tag size={16} color={c.textSecondary} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>{kw.keyword}</Text>
|
||||
<Text style={styles.rowCount}>{kw.count}</Text>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
height: componentSizes.inputHeight,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
addBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
addBtnDisabled: { opacity: 0.4 },
|
||||
empty: { ...typography.caption, color: c.textMuted, textAlign: 'center', paddingVertical: spacing.xl },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
rowCount: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { default as AddressBookPickerSheet } from './AddressBookPickerSheet';
|
||||
export { default as ContactImportSheet } from './ContactImportSheet';
|
||||
export { default as ContactListRow } from './ContactListRow';
|
||||
export { default as ContactPickerSheet } from './ContactPickerSheet';
|
||||
export { default as ContactsSidebarDrawer } from './ContactsSidebarDrawer';
|
||||
export { default as TagAssignSheet } from './TagAssignSheet';
|
||||
export { default as FieldBlock, FieldRow } from './FieldBlock';
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native';
|
||||
import {
|
||||
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
|
||||
} from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Email, CalendarEvent } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { parseCalendarBlob } from '../../api/calendar';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
findParticipantByEmail,
|
||||
getOrganizerName,
|
||||
buildReplyTo,
|
||||
inferInvitationMethod,
|
||||
type InvitationMethod,
|
||||
} from '../../lib/calendar-invitation';
|
||||
|
||||
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
}
|
||||
|
||||
export function CalendarInvitationBanner({ email }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
||||
|
||||
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
|
||||
|
||||
const [state, setState] = React.useState<BannerState>('loading');
|
||||
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
|
||||
const [method, setMethod] = React.useState<InvitationMethod>('unknown');
|
||||
const [rsvpStatus, setRsvpStatus] = React.useState<RsvpStatus | null>(null);
|
||||
const [notice, setNotice] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!attachment || !enabled) return;
|
||||
setState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const events = await parseCalendarBlob(attachment.blobId);
|
||||
if (cancelled) return;
|
||||
if (events.length === 0) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
const parsed = events[0];
|
||||
setEvent(parsed);
|
||||
setMethod(inferInvitationMethod(parsed));
|
||||
setState('parsed');
|
||||
} catch {
|
||||
if (!cancelled) setState('error');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [attachment, enabled]);
|
||||
|
||||
if (!attachment || !enabled || state === 'error') return null;
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<Text style={styles.loadingText}>Reading invitation…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!event) return null;
|
||||
|
||||
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
|
||||
const startDate = startStr ? parseISO(startStr) : null;
|
||||
const dateLabel = startDate && !isNaN(startDate.getTime())
|
||||
? (event.showWithoutTime
|
||||
? format(startDate, 'EEEE, MMM d, yyyy')
|
||||
: format(startDate, 'EEE, MMM d · HH:mm'))
|
||||
: null;
|
||||
const organizer = getOrganizerName(event);
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
||||
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
|
||||
|
||||
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
|
||||
|
||||
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
|
||||
if (busy || !writableCalendar) return;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
// Make sure the event exists in a local calendar (dedupes by UID).
|
||||
await importEvents([event], writableCalendar.id);
|
||||
// Re-read from the store to get the server-assigned id + participant.
|
||||
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
|
||||
const participant = stored && activeEmail
|
||||
? findParticipantByEmail(stored, [activeEmail])
|
||||
: me;
|
||||
if (stored && participant) {
|
||||
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
|
||||
setRsvpStatus(status);
|
||||
setNotice('Response sent');
|
||||
} else {
|
||||
setNotice('Added to calendar');
|
||||
}
|
||||
setState('done');
|
||||
} catch {
|
||||
setNotice('Could not send your response');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (busy || !writableCalendar) return;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const count = await importEvents([event], writableCalendar.id);
|
||||
setNotice(count > 0 ? 'Added to calendar' : 'Already in your calendar');
|
||||
setState('done');
|
||||
} catch {
|
||||
setNotice('Could not add the event');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.iconBadge}>
|
||||
<CalendarDays size={18} color={c.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.title} numberOfLines={2}>{event.title || 'Calendar invitation'}</Text>
|
||||
{method === 'cancel' && <Text style={styles.cancelled}>This event was cancelled</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{dateLabel && (
|
||||
<Row icon={<Clock size={15} color={c.textMuted} />} text={dateLabel} styles={styles} />
|
||||
)}
|
||||
{location ? (
|
||||
<Row icon={<MapPin size={15} color={c.textMuted} />} text={location} styles={styles} />
|
||||
) : null}
|
||||
{videoUri ? (
|
||||
<Pressable style={styles.detailRow} onPress={() => { void Linking.openURL(videoUri); }}>
|
||||
<Video size={15} color={c.textMuted} />
|
||||
<Text style={[styles.detailText, { color: c.primary }]} numberOfLines={1}>Join video call</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{organizer ? (
|
||||
<Row icon={<CalendarPlus size={15} color={c.textMuted} />} text={`Organizer: ${organizer}`} styles={styles} />
|
||||
) : null}
|
||||
|
||||
{notice && <Text style={styles.notice}>{notice}</Text>}
|
||||
|
||||
{state !== 'done' && (
|
||||
<View style={styles.actions}>
|
||||
{canRsvp ? (
|
||||
<>
|
||||
<RsvpBtn label="Yes" active={rsvpStatus === 'accepted'} activeColor={c.success}
|
||||
icon={<Check size={15} color={rsvpStatus === 'accepted' ? c.textInverse : c.success} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} />
|
||||
<RsvpBtn label="Maybe" active={rsvpStatus === 'tentative'} activeColor={c.warning}
|
||||
icon={<HelpCircle size={15} color={rsvpStatus === 'tentative' ? c.textInverse : c.warning} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} />
|
||||
<RsvpBtn label="No" active={rsvpStatus === 'declined'} activeColor={c.error}
|
||||
icon={<X size={15} color={rsvpStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
style={[styles.importBtn, busy && { opacity: 0.5 }]}
|
||||
onPress={() => { void handleImport(); }}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.primaryForeground} />
|
||||
) : (
|
||||
<CalendarPlus size={16} color={c.primaryForeground} />
|
||||
)}
|
||||
<Text style={styles.importBtnText}>Add to calendar</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!writableCalendar && state !== 'done' && (
|
||||
<View style={styles.warnRow}>
|
||||
<AlertTriangle size={14} color={c.warning} />
|
||||
<Text style={styles.warnText}>No writable calendar available</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon, text, styles,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailRow}>
|
||||
{icon}
|
||||
<Text style={styles.detailText} numberOfLines={2}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RsvpBtn({
|
||||
label, icon, active, activeColor, disabled, onPress, c, styles,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
activeColor: string;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
c: ThemePalette;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.rsvpBtn,
|
||||
active && { backgroundColor: activeColor, borderColor: activeColor },
|
||||
disabled && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
{icon}
|
||||
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
loadingText: { ...typography.caption, color: c.textMuted },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.xs },
|
||||
iconBadge: {
|
||||
width: 32, height: 32, borderRadius: radius.sm,
|
||||
backgroundColor: c.primaryBg, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
cancelled: { ...typography.caption, color: c.error, marginTop: 2 },
|
||||
detailRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 2 },
|
||||
detailText: { flex: 1, ...typography.caption, color: c.textSecondary },
|
||||
notice: { ...typography.captionMedium, color: c.primary, marginTop: spacing.xs },
|
||||
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
|
||||
rsvpBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
rsvpBtnText: { ...typography.caption, color: c.text },
|
||||
importBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
importBtnText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs },
|
||||
warnText: { ...typography.caption, color: c.warning },
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import { ExternalLink } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { CloudDownload, ExternalLink } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '1', label: 'Last 24 hours' },
|
||||
{ value: '3', label: 'Last 3 days' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
{ value: '14', label: 'Last 14 days' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
{ value: '90', label: 'Last 90 days' },
|
||||
];
|
||||
|
||||
function formatRelativeTime(ms: number | undefined): string {
|
||||
if (!ms) return 'never';
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;
|
||||
if (diff < 24 * 60 * 60_000) return `${Math.round(diff / (60 * 60_000))} h ago`;
|
||||
return `${Math.round(diff / (24 * 60 * 60_000))} d ago`;
|
||||
}
|
||||
|
||||
const DEBUG_CATEGORIES = [
|
||||
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
|
||||
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
|
||||
@@ -18,6 +39,8 @@ const DEBUG_CATEGORIES = [
|
||||
];
|
||||
|
||||
export function AboutDataSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
@@ -33,9 +56,36 @@ export function AboutDataSettings() {
|
||||
const [syncEnabled, setSyncEnabled] = useState(true);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
|
||||
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
|
||||
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
|
||||
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
|
||||
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
if (!cacheHydrated) void cacheHydrate();
|
||||
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
|
||||
|
||||
const handleSyncNow = () => {
|
||||
void runOfflineSync({ days: offlineDays });
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
Alert.alert(
|
||||
'Clear offline mail',
|
||||
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetExternalContentPolicy('ask');
|
||||
@@ -64,11 +114,85 @@ export function AboutDataSettings() {
|
||||
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
|
||||
>
|
||||
<Text style={styles.ghText}>GitHub</Text>
|
||||
<ExternalLink size={12} color={colors.mutedForeground} />
|
||||
<ExternalLink size={12} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingsSection
|
||||
title="Offline mail"
|
||||
description="Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand."
|
||||
>
|
||||
<SettingItem
|
||||
label="Cache recent mail"
|
||||
description="When on, the app keeps message bodies for the selected window on this device."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={offlineEnabled}
|
||||
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Window"
|
||||
description="How far back to cache, measured by message receipt date."
|
||||
>
|
||||
<Select
|
||||
value={String(offlineDays)}
|
||||
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
|
||||
options={DAY_OPTIONS}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.cacheStatsBox}>
|
||||
<View style={styles.cacheStatsHeader}>
|
||||
<CloudDownload size={16} color={c.textSecondary} />
|
||||
<Text style={styles.cacheStatsTitle}>
|
||||
{cacheCount === 0
|
||||
? 'Nothing cached yet'
|
||||
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.cacheStatsSub}>
|
||||
{sync.phase === 'fetching'
|
||||
? `Downloading ${sync.completed}/${sync.total}…`
|
||||
: sync.phase === 'scanning'
|
||||
? 'Scanning recent mail…'
|
||||
: sync.phase === 'error'
|
||||
? `Last sync failed: ${sync.message ?? 'unknown error'}`
|
||||
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
|
||||
</Text>
|
||||
{syncBusy && sync.total > 0 && (
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.cacheActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleSyncNow}
|
||||
disabled={!offlineEnabled || syncBusy}
|
||||
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
|
||||
>
|
||||
{syncBusy ? 'Syncing…' : 'Sync now'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleClearCache}
|
||||
disabled={cacheCount === 0 || syncBusy}
|
||||
>
|
||||
Clear cache
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
|
||||
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
|
||||
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
|
||||
@@ -122,14 +246,15 @@ export function AboutDataSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
aboutBox: {
|
||||
padding: spacing.xl,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.card,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.card,
|
||||
},
|
||||
aboutRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -140,30 +265,55 @@ const styles = StyleSheet.create({
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logoText: { fontSize: 24, fontWeight: '700', color: colors.primaryForeground },
|
||||
aboutTitle: { ...typography.bodyMedium, color: colors.text },
|
||||
aboutVersion: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
||||
aboutCommit: { color: colors.mutedForeground, opacity: 0.6 },
|
||||
logoText: { fontSize: 24, fontWeight: '700', color: c.primaryForeground },
|
||||
aboutTitle: { ...typography.bodyMedium, color: c.text },
|
||||
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
aboutCommit: { color: c.mutedForeground, opacity: 0.6 },
|
||||
ghLink: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
ghText: { ...typography.caption, color: colors.mutedForeground },
|
||||
ghText: { ...typography.caption, color: c.mutedForeground },
|
||||
debugCategoriesBox: {
|
||||
marginLeft: spacing.lg,
|
||||
paddingLeft: spacing.lg,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.muted,
|
||||
borderLeftColor: c.muted,
|
||||
gap: 4,
|
||||
},
|
||||
debugCategoriesHint: {
|
||||
...typography.caption,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cacheStatsBox: {
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cacheStatsHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
|
||||
progressTrack: {
|
||||
height: 4,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: { height: '100%', backgroundColor: c.primary },
|
||||
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import {
|
||||
clearClientCertAlias,
|
||||
getClientCertAlias,
|
||||
isClientCertSupported,
|
||||
pickClientCertAlias,
|
||||
} from '../../lib/client-cert';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
@@ -19,6 +27,8 @@ const MOCK_SESSIONS: Session[] = [
|
||||
];
|
||||
|
||||
export function AccountSecuritySettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [currentPwd, setCurrentPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
@@ -27,8 +37,96 @@ export function AccountSecuritySettings() {
|
||||
const [twoFactor, setTwoFactor] = useState(false);
|
||||
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
|
||||
|
||||
const certSupported = isClientCertSupported();
|
||||
const [certAlias, setCertAlias] = useState<string | null>(null);
|
||||
const [certBusy, setCertBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certSupported) return;
|
||||
void getClientCertAlias().then(setCertAlias);
|
||||
}, [certSupported]);
|
||||
|
||||
const onPickCert = async () => {
|
||||
if (certBusy) return;
|
||||
setCertBusy(true);
|
||||
try {
|
||||
// Hint the picker with the JMAP server host so it can pre-filter to certs
|
||||
// issued for it when the OEM picker honours that.
|
||||
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
|
||||
const alias = await pickClientCertAlias(host);
|
||||
setCertAlias(alias);
|
||||
} catch (err) {
|
||||
Alert.alert('Pick failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearCert = async () => {
|
||||
if (certBusy) return;
|
||||
Alert.alert(
|
||||
'Stop using client certificate?',
|
||||
'New requests will no longer present a certificate. The certificate stays installed in Android.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Clear',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setCertBusy(true);
|
||||
try {
|
||||
await clearClientCertAlias();
|
||||
setCertAlias(null);
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{certSupported && (
|
||||
<SettingsSection
|
||||
title="TLS client certificate"
|
||||
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
|
||||
>
|
||||
<View style={styles.certRow}>
|
||||
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certStatus}>
|
||||
{certAlias ? `Active: ${certAlias}` : 'Not set'}
|
||||
</Text>
|
||||
<Text style={styles.certHint}>
|
||||
Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.certActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onPickCert(); }}
|
||||
>
|
||||
{certAlias ? 'Choose another' : 'Pick certificate'}
|
||||
</Button>
|
||||
{certAlias && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onClearCert(); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Password" description="Change the password for this account.">
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.pwField}>
|
||||
@@ -42,9 +140,9 @@ export function AccountSecuritySettings() {
|
||||
/>
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
|
||||
{showCurrent ? (
|
||||
<EyeOff size={16} color={colors.mutedForeground} />
|
||||
<EyeOff size={16} color={c.mutedForeground} />
|
||||
) : (
|
||||
<Eye size={16} color={colors.mutedForeground} />
|
||||
<Eye size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -61,9 +159,9 @@ export function AccountSecuritySettings() {
|
||||
/>
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
|
||||
{showNew ? (
|
||||
<EyeOff size={16} color={colors.mutedForeground} />
|
||||
<EyeOff size={16} color={c.mutedForeground} />
|
||||
) : (
|
||||
<Eye size={16} color={colors.mutedForeground} />
|
||||
<Eye size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -79,7 +177,7 @@ export function AccountSecuritySettings() {
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Button size="sm" icon={<Key size={14} color={colors.primaryForeground} />}>
|
||||
<Button size="sm" icon={<Key size={14} color={c.primaryForeground} />}>
|
||||
Change Password
|
||||
</Button>
|
||||
</View>
|
||||
@@ -93,7 +191,7 @@ export function AccountSecuritySettings() {
|
||||
|
||||
{twoFactor && (
|
||||
<View style={styles.infoBox}>
|
||||
<Smartphone size={16} color={colors.primary} />
|
||||
<Smartphone size={16} color={c.primary} />
|
||||
<Text style={styles.infoText}>
|
||||
Scan the QR code in your authenticator app to finish setup.
|
||||
</Text>
|
||||
@@ -104,7 +202,7 @@ export function AccountSecuritySettings() {
|
||||
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
|
||||
{appPwds.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Lock size={24} color={colors.mutedForeground} />
|
||||
<Lock size={24} color={c.mutedForeground} />
|
||||
<Text style={styles.emptyText}>No app passwords yet.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -119,7 +217,7 @@ export function AccountSecuritySettings() {
|
||||
{MOCK_SESSIONS.map((s) => (
|
||||
<View key={s.id} style={styles.sessionRow}>
|
||||
<View style={styles.sessionIcon}>
|
||||
<Monitor size={18} color={colors.text} />
|
||||
<Monitor size={18} color={c.text} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.sessionDevice}>{s.device}</Text>
|
||||
@@ -129,7 +227,7 @@ export function AccountSecuritySettings() {
|
||||
</View>
|
||||
{!s.current && (
|
||||
<Pressable style={styles.trashBtn}>
|
||||
<Trash2 size={16} color={colors.error} />
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -139,23 +237,24 @@ export function AccountSecuritySettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
pwField: { gap: 4 },
|
||||
pwLabel: { ...typography.caption, color: colors.mutedForeground },
|
||||
pwLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
eyeBtn: { padding: spacing.sm },
|
||||
infoBox: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.primaryBg,
|
||||
backgroundColor: c.primaryBg,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoText: {
|
||||
...typography.caption,
|
||||
color: colors.primary,
|
||||
color: c.primary,
|
||||
flex: 1,
|
||||
},
|
||||
empty: {
|
||||
@@ -165,7 +264,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
sessionRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -173,18 +272,18 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sessionIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sessionDevice: { ...typography.bodyMedium, color: colors.text },
|
||||
sessionMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
||||
sessionDevice: { ...typography.bodyMedium, color: c.text },
|
||||
sessionMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
trashBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -192,4 +291,14 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
certRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
certStatus: { ...typography.bodyMedium, color: c.text },
|
||||
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
|
||||
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
|
||||
@@ -29,6 +30,8 @@ function formatFileSize(bytes: number): string {
|
||||
}
|
||||
|
||||
export function AccountSettings(props: AccountSettingsProps = {}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const authUsername = useAuthStore((s) => s.username);
|
||||
const authServerUrl = useAuthStore((s) => s.serverUrl);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
@@ -99,10 +102,11 @@ export function AccountSettings(props: AccountSettingsProps = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
value: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
maxWidth: 240,
|
||||
},
|
||||
storage: {
|
||||
@@ -112,13 +116,13 @@ const styles = StyleSheet.create({
|
||||
bar: {
|
||||
width: 128,
|
||||
height: 8,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.full,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
barFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
demoRow: {
|
||||
@@ -137,3 +141,4 @@ const styles = StyleSheet.create({
|
||||
color: '#fbbf24',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { PlayCircle } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type FontSize = 'small' | 'medium' | 'large';
|
||||
type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
type ToolbarPosition = 'top' | 'below-subject';
|
||||
type Theme = ThemeMode;
|
||||
|
||||
const DENSITY_PREVIEW: Record<Density, { py: number; gap: number; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'extra-compact': { py: 2, gap: 6, showAvatar: false, showPreview: false },
|
||||
@@ -24,6 +21,8 @@ const PREVIEW_ROWS = [
|
||||
];
|
||||
|
||||
function DensityPreview({ density }: { density: Density }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const cfg = DENSITY_PREVIEW[density];
|
||||
return (
|
||||
<View style={styles.densityPreview}>
|
||||
@@ -50,7 +49,7 @@ function DensityPreview({ density }: { density: Density }) {
|
||||
<Text
|
||||
style={[
|
||||
styles.densitySender,
|
||||
row.unread ? { color: colors.text, fontWeight: '600' } : { color: colors.mutedForeground },
|
||||
row.unread ? { color: c.text, fontWeight: '600' } : { color: c.mutedForeground },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -61,7 +60,7 @@ function DensityPreview({ density }: { density: Density }) {
|
||||
<Text
|
||||
style={[
|
||||
styles.densitySubject,
|
||||
row.unread ? { color: colors.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
|
||||
row.unread ? { color: c.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -80,16 +79,24 @@ function DensityPreview({ density }: { density: Density }) {
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const [theme, setTheme] = useState<Theme>('dark');
|
||||
const [fontSize, setFontSize] = useState<FontSize>('medium');
|
||||
const [density, setDensity] = useState<Density>('regular');
|
||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition>('top');
|
||||
const [showToolbarLabels, setShowToolbarLabels] = useState(true);
|
||||
const [hideAccountSwitcher, setHideAccountSwitcher] = useState(false);
|
||||
const [showRailAccountList, setShowRailAccountList] = useState(false);
|
||||
const [colorfulSidebarIcons, setColorfulSidebarIcons] = useState(true);
|
||||
const [animationsEnabled, setAnimationsEnabled] = useState(true);
|
||||
const [language, setLanguage] = useState('en');
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const setTheme = useSettingsStore((s) => s.setTheme);
|
||||
const fontSize = useSettingsStore((s) => s.fontSize);
|
||||
const setFontSize = useSettingsStore((s) => s.setFontSize);
|
||||
const density = useSettingsStore((s) => s.density);
|
||||
const setDensity = useSettingsStore((s) => s.setDensity);
|
||||
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
|
||||
const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels);
|
||||
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
|
||||
const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
|
||||
@@ -105,18 +112,6 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Language" description="Interface language.">
|
||||
<RadioGroup
|
||||
value={language}
|
||||
onChange={setLanguage}
|
||||
options={[
|
||||
{ value: 'en', label: 'EN' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
{ value: 'fr', label: 'FR' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Font Size" description="Adjust text size.">
|
||||
<RadioGroup
|
||||
value={fontSize}
|
||||
@@ -145,64 +140,36 @@ export function AppearanceSettings() {
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
|
||||
<SettingItem label="Toolbar Position" description="Where to place the message toolbar.">
|
||||
<RadioGroup
|
||||
value={toolbarPosition}
|
||||
onChange={(v) => setToolbarPosition(v as ToolbarPosition)}
|
||||
options={[
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'below-subject', label: 'Below' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Toolbar Labels" description="Show text labels on toolbar buttons.">
|
||||
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Hide Account Switcher" description="Hide the account switcher from the navigation rail.">
|
||||
<ToggleSwitch checked={hideAccountSwitcher} onChange={setHideAccountSwitcher} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Rail Account List" description="Show all accounts inside the navigation rail.">
|
||||
<ToggleSwitch checked={showRailAccountList} onChange={setShowRailAccountList} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Colorful Sidebar Icons" description="Display sidebar icons with their brand colors.">
|
||||
<ToggleSwitch checked={colorfulSidebarIcons} onChange={setColorfulSidebarIcons} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Animations" description="Enable or disable interface animations.">
|
||||
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Restart Tour" description="Replay the onboarding walkthrough.">
|
||||
<Button variant="outline" size="sm" icon={<PlayCircle size={14} color={colors.text} />}>
|
||||
Restart
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
densityPreview: {
|
||||
marginTop: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
densityRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
densityAvatar: {
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
densityHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -216,7 +183,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
densityTime: {
|
||||
fontSize: 10,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
densitySubject: {
|
||||
...typography.caption,
|
||||
@@ -227,7 +194,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
backgroundColor: c.border,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type CalendarView,
|
||||
type FirstDayOfWeek,
|
||||
type TimeFormat,
|
||||
type CalendarHoverPreview,
|
||||
} from '../../stores/settings-store';
|
||||
|
||||
export function CalendarSettings() {
|
||||
const [viewMode, setViewMode] = useState('month');
|
||||
const [firstDay, setFirstDay] = useState('1');
|
||||
const [timeFormat, setTimeFormat] = useState('24h');
|
||||
const [showTimeInMonth, setShowTimeInMonth] = useState(true);
|
||||
const [showWeekNumbers, setShowWeekNumbers] = useState(false);
|
||||
const [hoverPreview, setHoverPreview] = useState('delay-500ms');
|
||||
const [birthdayCal, setBirthdayCal] = useState(true);
|
||||
const [tasksEnabled, setTasksEnabled] = useState(false);
|
||||
const [showTasksOnCal, setShowTasksOnCal] = useState(true);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const viewMode = useSettingsStore((s) => s.calendarDefaultView);
|
||||
const firstDay = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||
const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
|
||||
const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
const hoverPreview = useSettingsStore((s) => s.calendarHoverPreview);
|
||||
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Calendar">
|
||||
<SettingItem label="Default view">
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
@@ -29,8 +44,8 @@ export function CalendarSettings() {
|
||||
|
||||
<SettingItem label="Week starts on">
|
||||
<Select
|
||||
value={firstDay}
|
||||
onChange={setFirstDay}
|
||||
value={String(firstDay)}
|
||||
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: 'Monday' },
|
||||
{ value: '0', label: 'Sunday' },
|
||||
@@ -41,7 +56,7 @@ export function CalendarSettings() {
|
||||
<SettingItem label="Time format">
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={setTimeFormat}
|
||||
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: '12-hour' },
|
||||
{ value: '24h', label: '24-hour' },
|
||||
@@ -53,14 +68,20 @@ export function CalendarSettings() {
|
||||
label="Show time in month view"
|
||||
description="Show start times next to events in the month grid."
|
||||
>
|
||||
<ToggleSwitch checked={showTimeInMonth} onChange={setShowTimeInMonth} />
|
||||
<ToggleSwitch
|
||||
checked={showTimeInMonth}
|
||||
onChange={(v) => update('calendarShowTimeInMonth', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Show week numbers"
|
||||
description="Display ISO week numbers in month and week views."
|
||||
>
|
||||
<ToggleSwitch checked={showWeekNumbers} onChange={setShowWeekNumbers} />
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
onChange={(v) => update('calendarShowWeekNumbers', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
@@ -69,7 +90,7 @@ export function CalendarSettings() {
|
||||
>
|
||||
<Select
|
||||
value={hoverPreview}
|
||||
onChange={setHoverPreview}
|
||||
onChange={(v) => update('calendarHoverPreview', v as CalendarHoverPreview)}
|
||||
options={[
|
||||
{ value: 'instant', label: 'Instant' },
|
||||
{ value: 'delay-500ms', label: '500ms delay' },
|
||||
@@ -84,14 +105,20 @@ export function CalendarSettings() {
|
||||
label="Show birthday calendar"
|
||||
description="Automatically include birthdays from your contacts."
|
||||
>
|
||||
<ToggleSwitch checked={birthdayCal} onChange={setBirthdayCal} />
|
||||
<ToggleSwitch
|
||||
checked={birthdayCal}
|
||||
onChange={(v) => update('showBirthdayCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Enable tasks"
|
||||
description="Show task management alongside events."
|
||||
>
|
||||
<ToggleSwitch checked={tasksEnabled} onChange={setTasksEnabled} />
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{tasksEnabled && (
|
||||
@@ -99,7 +126,10 @@ export function CalendarSettings() {
|
||||
label="Show tasks on calendar"
|
||||
description="Overlay tasks with due dates on the calendar."
|
||||
>
|
||||
<ToggleSwitch checked={showTasksOnCal} onChange={setShowTasksOnCal} />
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCal}
|
||||
onChange={(v) => update('showTasksOnCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { X, Plus } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const addKeyword = () => {
|
||||
const trimmed = newKeyword.trim().toLowerCase();
|
||||
if (!trimmed || attachmentReminderKeywords.includes(trimmed)) {
|
||||
setNewKeyword('');
|
||||
return;
|
||||
}
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, trimmed]);
|
||||
setNewKeyword('');
|
||||
};
|
||||
|
||||
const removeKeyword = (kw: string) => {
|
||||
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
|
||||
};
|
||||
|
||||
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
|
||||
{ label: 'Off', value: 0 },
|
||||
{ label: '5s', value: 5 },
|
||||
{ label: '10s', value: 10 },
|
||||
{ label: '20s', value: 20 },
|
||||
{ label: '30s', value: 30 },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Composing"
|
||||
description="How the composer behaves when you reply, forward, or send."
|
||||
>
|
||||
<SettingItem
|
||||
label="Auto-select reply identity"
|
||||
description="When replying, pick the identity that received the original message."
|
||||
>
|
||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.subBlock}>
|
||||
<Text style={styles.subLabel}>Undo send delay</Text>
|
||||
<Text style={styles.subDescription}>
|
||||
Hold outgoing mail for a few seconds so you can cancel it. Requires server support.
|
||||
</Text>
|
||||
<View style={styles.segmentRow}>
|
||||
{SEND_DELAY_OPTIONS.map((opt) => {
|
||||
const active = sendDelaySeconds === opt.value;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
onPress={() => updateSetting('sendDelaySeconds', opt.value)}
|
||||
style={[styles.segment, active && styles.segmentActive]}
|
||||
>
|
||||
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingItem
|
||||
label="Attachment reminder"
|
||||
description="Warn before sending if the message mentions an attachment but none is attached."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={setAttachmentReminderEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{attachmentReminderEnabled && (
|
||||
<View style={styles.subBlock}>
|
||||
<Text style={styles.subLabel}>Trigger keywords</Text>
|
||||
<Text style={styles.subDescription}>
|
||||
Words that, when found in the body or subject without an attachment, trigger the reminder.
|
||||
</Text>
|
||||
|
||||
<View style={styles.chips}>
|
||||
{attachmentReminderKeywords.map((kw) => (
|
||||
<View key={kw} style={styles.chip}>
|
||||
<Text style={styles.chipText}>{kw}</Text>
|
||||
<Pressable onPress={() => removeKeyword(kw)} hitSlop={6}>
|
||||
<X size={12} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.addRow}>
|
||||
<TextInput
|
||||
value={newKeyword}
|
||||
onChangeText={setNewKeyword}
|
||||
placeholder="e.g. attached"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
onSubmitEditing={addKeyword}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={addKeyword}
|
||||
disabled={!newKeyword.trim()}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
|
||||
subLabel: { ...typography.bodyMedium, color: c.text },
|
||||
subDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
|
||||
segment: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
segmentActive: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
segmentText: { ...typography.caption, color: c.text },
|
||||
segmentTextActive: { color: c.primaryForeground, fontWeight: '600' },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
chipText: { ...typography.caption, color: c.text },
|
||||
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,28 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Alert, Share, Text } from 'react-native';
|
||||
import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { Download } from 'lucide-react-native';
|
||||
import {
|
||||
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
import { ContactImportSheet } from '../contacts';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
|
||||
import { contactsToVCard } from '../../lib/vcard';
|
||||
import { isGroup } from '../../lib/contact-utils';
|
||||
import { colors, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
export function ContactsSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const books = useContactsStore(selectAddressBooksWithCount);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const createAddressBook = useContactsStore((s) => s.createAddressBook);
|
||||
const renameAddressBook = useContactsStore((s) => s.renameAddressBook);
|
||||
const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook);
|
||||
|
||||
const [exporting, setExporting] = React.useState(false);
|
||||
const [importOpen, setImportOpen] = React.useState(false);
|
||||
|
||||
const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editName, setEditName] = React.useState('');
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [newName, setNewName] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
|
||||
|
||||
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
|
||||
const exportLabel =
|
||||
exportable.length === 0
|
||||
? 'No contacts to export'
|
||||
: `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`;
|
||||
const importTargetBookId = books[0]?.id ?? null;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (exportable.length === 0 || exporting) return;
|
||||
@@ -48,40 +70,256 @@ export function ContactsSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (id: string, name: string) => {
|
||||
setAdding(false);
|
||||
setEditingId(id);
|
||||
setEditName(name);
|
||||
};
|
||||
|
||||
const commitRename = async () => {
|
||||
const name = editName.trim();
|
||||
if (!editingId || !name || busy) { setEditingId(null); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await renameAddressBook(editingId, name);
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitCreate = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await createAddressBook(name);
|
||||
setAdding(false);
|
||||
setNewName('');
|
||||
} catch (err) {
|
||||
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const { id } = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
try {
|
||||
await deleteAddressBook(id);
|
||||
} catch (err) {
|
||||
Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Contacts"
|
||||
description="Display preferences and address-book tools."
|
||||
>
|
||||
<SettingItem
|
||||
label="Group by first letter"
|
||||
description="Show alphabetical section headers in the contacts list."
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Contacts"
|
||||
description="Display preferences and address-book tools."
|
||||
>
|
||||
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Export contacts"
|
||||
description={exportLabel}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Download size={14} color={colors.text} />}
|
||||
onPress={() => { void handleExport(); }}
|
||||
disabled={exportable.length === 0 || exporting}
|
||||
loading={exporting}
|
||||
<SettingItem
|
||||
label="Group by first letter"
|
||||
description="Show alphabetical section headers in the contacts list."
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Import contacts"
|
||||
description="Importing vCard files is currently available on the web client only."
|
||||
<SettingItem
|
||||
label="Import contacts"
|
||||
description="Import contacts from a vCard (.vcf) file with duplicate detection."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Upload size={14} color={c.text} />}
|
||||
onPress={() => setImportOpen(true)}
|
||||
disabled={books.length === 0}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Export contacts"
|
||||
description={exportLabel}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Download size={14} color={c.text} />}
|
||||
onPress={() => { void handleExport(); }}
|
||||
disabled={exportable.length === 0 || exporting}
|
||||
loading={exporting}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Address books"
|
||||
description="Create, rename, and remove the address books that organize your contacts."
|
||||
>
|
||||
<Text style={[typography.caption, { color: colors.textMuted }]}>Web only</Text>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
{books.map((book) => (
|
||||
<View key={book.id} style={styles.bookRow}>
|
||||
<BookUser size={16} color={c.textSecondary} />
|
||||
{editingId === book.id ? (
|
||||
<>
|
||||
<TextInput
|
||||
style={styles.bookInput}
|
||||
value={editName}
|
||||
onChangeText={setEditName}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void commitRename(); }}
|
||||
placeholderTextColor={c.textMuted}
|
||||
/>
|
||||
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.iconBtn}>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setEditingId(null)} hitSlop={6} style={styles.iconBtn}>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.bookName} numberOfLines={1}>{book.name}</Text>
|
||||
<Text style={styles.bookCount}>
|
||||
{book.count} contact{book.count === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</View>
|
||||
{book.myRights?.mayWrite !== false && (
|
||||
<Pressable onPress={() => startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}>
|
||||
<Pencil size={15} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
{book.myRights?.mayDelete !== false && books.length > 1 && (
|
||||
<Pressable
|
||||
onPress={() => setDeleteTarget({ id: book.id, name: book.name, count: book.count })}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
>
|
||||
<Trash2 size={15} color={c.error} />
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{adding ? (
|
||||
<View style={styles.bookRow}>
|
||||
<BookUser size={16} color={c.textSecondary} />
|
||||
<TextInput
|
||||
style={styles.bookInput}
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
placeholder="New address book name"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void commitCreate(); }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => { void commitCreate(); }}
|
||||
disabled={!newName.trim() || busy}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
style={styles.addRow}
|
||||
onPress={() => { setEditingId(null); setAdding(true); }}
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={styles.addLabel}>New address book</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View style={{ marginTop: spacing.sm }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => { void fetchAddressBooks(); void fetchContacts(); }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<ContactImportSheet
|
||||
visible={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
targetBookId={importTargetBookId}
|
||||
targetBookName={books[0]?.name}
|
||||
onImported={() => { void fetchContacts(); }}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={deleteTarget !== null}
|
||||
title="Delete address book"
|
||||
message={
|
||||
deleteTarget
|
||||
? `Delete "${deleteTarget.name}"?${deleteTarget.count > 0 ? ` Its ${deleteTarget.count} contact${deleteTarget.count === 1 ? '' : 's'} will be removed from this book.` : ''} This cannot be undone.`
|
||||
: ''
|
||||
}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={() => { void confirmDelete(); }}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
bookRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
bookName: { ...typography.body, color: c.text },
|
||||
bookCount: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
bookInput: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
height: componentSizes.inputHeight,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
iconBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
addLabel: { ...typography.body, color: c.primary },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, TextInput, FlatList, Modal,
|
||||
} from 'react-native';
|
||||
import { ChevronRight, X, Plus, Trash2 } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
|
||||
|
||||
export function ContentSendersSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const setEmailAlwaysLightMode = useSettingsStore((s) => s.setEmailAlwaysLightMode);
|
||||
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [newSender, setNewSender] = useState('');
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const trustedCount = trustedSenders.length;
|
||||
const trustedLabel = trustedCount === 0 ? 'None' : trustedCount === 1 ? '1 sender' : `${trustedCount} senders`;
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
<SettingsSection
|
||||
title="Content & Senders"
|
||||
description="Control how external resources and tracking pixels are handled."
|
||||
>
|
||||
<SettingItem
|
||||
label="External content"
|
||||
description="What to do when an email links to remote images or media."
|
||||
>
|
||||
<RadioGroup
|
||||
value={externalContentPolicy}
|
||||
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
|
||||
options={[
|
||||
{ value: 'ask', label: 'Ask' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Always view emails in light mode"
|
||||
description="Force a light background for the message body, even when the app is in dark mode."
|
||||
>
|
||||
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Trusted senders"
|
||||
description="External content always loads for senders on this list."
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => setModalOpen(true)}
|
||||
style={({ pressed }) => [styles.trustedButton, pressed && styles.trustedButtonPressed]}
|
||||
>
|
||||
<Text style={styles.trustedButtonText}>{trustedLabel}</Text>
|
||||
<ChevronRight size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<Modal
|
||||
visible={modalOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setModalOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||
<Pressable style={styles.modalCard} onPress={() => {}}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Trusted senders</Text>
|
||||
<Pressable onPress={() => setModalOpen(false)} hitSlop={6}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.addRow}>
|
||||
<TextInput
|
||||
value={newSender}
|
||||
onChangeText={setNewSender}
|
||||
placeholder="sender@example.com"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
style={styles.input}
|
||||
onSubmitEditing={() => {
|
||||
if (newSender.trim()) {
|
||||
addTrustedSender(newSender);
|
||||
setNewSender('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
if (newSender.trim()) {
|
||||
addTrustedSender(newSender);
|
||||
setNewSender('');
|
||||
}
|
||||
}}
|
||||
disabled={!newSender.trim()}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{trustedSenders.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No trusted senders yet.</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={trustedSenders}
|
||||
keyExtractor={(item) => item}
|
||||
style={styles.list}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowText} numberOfLines={1}>{item}</Text>
|
||||
<Pressable
|
||||
onPress={() => removeTrustedSender(item)}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => [styles.removeBtn, pressed && styles.removeBtnPressed]}
|
||||
>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
trustedButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
trustedButtonPressed: { opacity: 0.7 },
|
||||
trustedButtonText: { ...typography.body, color: c.text },
|
||||
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
padding: spacing.lg,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
modalTitle: { ...typography.h3, color: c.text },
|
||||
addRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
list: { maxHeight: 300 },
|
||||
separator: { height: 1, backgroundColor: c.border },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
rowText: { ...typography.body, color: c.text, flex: 1, marginRight: spacing.sm },
|
||||
removeBtn: { padding: 6, borderRadius: radius.sm },
|
||||
removeBtnPressed: { backgroundColor: c.muted },
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: c.textMuted,
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,38 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
|
||||
type FolderLayout = 'inline' | 'sidebar';
|
||||
type ViewMode = 'list' | 'grid';
|
||||
type SortKey = 'name' | 'size' | 'modified';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type FilesFolderLayout,
|
||||
type FilesViewMode,
|
||||
type FilesSortKey,
|
||||
type FilesSortDir,
|
||||
} from '../../stores/settings-store';
|
||||
|
||||
interface FilesPrefs {
|
||||
folderLayout: FolderLayout;
|
||||
defaultViewMode: ViewMode;
|
||||
defaultSortKey: SortKey;
|
||||
defaultSortDir: SortDir;
|
||||
folderLayout: FilesFolderLayout;
|
||||
defaultViewMode: FilesViewMode;
|
||||
defaultSortKey: FilesSortKey;
|
||||
defaultSortDir: FilesSortDir;
|
||||
showIcons: boolean;
|
||||
coloredIcons: boolean;
|
||||
showThumbnails: boolean;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: FilesPrefs = {
|
||||
folderLayout: 'inline',
|
||||
defaultViewMode: 'list',
|
||||
defaultSortKey: 'name',
|
||||
defaultSortDir: 'asc',
|
||||
showIcons: true,
|
||||
coloredIcons: true,
|
||||
showThumbnails: true,
|
||||
showHiddenFiles: false,
|
||||
};
|
||||
|
||||
interface SampleFile {
|
||||
name: string;
|
||||
isFolder: boolean;
|
||||
@@ -57,24 +49,26 @@ function formatSize(b: number) {
|
||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getIcon(file: SampleFile, colored: boolean, sz: number) {
|
||||
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : colors.mutedForeground} />;
|
||||
function getIcon(c: ThemePalette, file: SampleFile, colored: boolean, sz: number) {
|
||||
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : c.mutedForeground} />;
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'jpg': case 'png': case 'gif':
|
||||
return <ImageIcon size={sz} color={colored ? '#4ade80' : colors.mutedForeground} />;
|
||||
return <ImageIcon size={sz} color={colored ? '#4ade80' : c.mutedForeground} />;
|
||||
case 'mp3': case 'wav':
|
||||
return <FileAudio size={sz} color={colored ? '#a78bfa' : colors.mutedForeground} />;
|
||||
return <FileAudio size={sz} color={colored ? '#a78bfa' : c.mutedForeground} />;
|
||||
case 'pdf':
|
||||
return <FileText size={sz} color={colored ? '#f87171' : colors.mutedForeground} />;
|
||||
return <FileText size={sz} color={colored ? '#f87171' : c.mutedForeground} />;
|
||||
case 'md': case 'json': case 'js': case 'ts':
|
||||
return <FileCode size={sz} color={colored ? '#fbbf24' : colors.mutedForeground} />;
|
||||
return <FileCode size={sz} color={colored ? '#fbbf24' : c.mutedForeground} />;
|
||||
default:
|
||||
return <File size={sz} color={colors.mutedForeground} />;
|
||||
return <File size={sz} color={c.mutedForeground} />;
|
||||
}
|
||||
}
|
||||
|
||||
function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const files = SAMPLE.filter((f) => {
|
||||
if (!prefs.showHiddenFiles && f.hidden) return false;
|
||||
return true;
|
||||
@@ -93,7 +87,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||
<View style={styles.gridWrap}>
|
||||
{files.map((f) => (
|
||||
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
|
||||
{prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
|
||||
{prefs.showIcons ? getIcon(c, f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
|
||||
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -111,7 +105,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||
</View>
|
||||
{files.map((f) => (
|
||||
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
|
||||
{prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)}
|
||||
{prefs.showIcons && getIcon(c, f, prefs.coloredIcons, 14)}
|
||||
<Text
|
||||
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
|
||||
numberOfLines={1}
|
||||
@@ -127,8 +121,35 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||
}
|
||||
|
||||
export function FilesSettings() {
|
||||
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
|
||||
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const set = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const folderLayout = useSettingsStore((s) => s.filesFolderLayout);
|
||||
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
|
||||
const defaultSortKey = useSettingsStore((s) => s.filesDefaultSortKey);
|
||||
const defaultSortDir = useSettingsStore((s) => s.filesDefaultSortDir);
|
||||
const showIcons = useSettingsStore((s) => s.filesShowIcons);
|
||||
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
|
||||
const showThumbnails = useSettingsStore((s) => s.filesShowThumbnails);
|
||||
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const prefs: FilesPrefs = {
|
||||
folderLayout,
|
||||
defaultViewMode,
|
||||
defaultSortKey,
|
||||
defaultSortDir,
|
||||
showIcons,
|
||||
coloredIcons,
|
||||
showThumbnails,
|
||||
showHiddenFiles,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -140,8 +161,8 @@ export function FilesSettings() {
|
||||
<SettingsSection title="Display" description="How files and folders are presented.">
|
||||
<SettingItem label="Folder layout" description="Choose how folders appear in the browser.">
|
||||
<RadioGroup
|
||||
value={prefs.folderLayout}
|
||||
onChange={(v) => update({ folderLayout: v as FolderLayout })}
|
||||
value={folderLayout}
|
||||
onChange={(v) => set('filesFolderLayout', v as FilesFolderLayout)}
|
||||
options={[
|
||||
{ value: 'inline', label: 'Inline' },
|
||||
{ value: 'sidebar', label: 'Sidebar' },
|
||||
@@ -151,8 +172,8 @@ export function FilesSettings() {
|
||||
|
||||
<SettingItem label="Default view" description="List or grid view.">
|
||||
<RadioGroup
|
||||
value={prefs.defaultViewMode}
|
||||
onChange={(v) => update({ defaultViewMode: v as ViewMode })}
|
||||
value={defaultViewMode}
|
||||
onChange={(v) => set('filesDefaultViewMode', v as FilesViewMode)}
|
||||
options={[
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
@@ -162,8 +183,8 @@ export function FilesSettings() {
|
||||
|
||||
<SettingItem label="Default sort" description="Primary sort key.">
|
||||
<RadioGroup
|
||||
value={prefs.defaultSortKey}
|
||||
onChange={(v) => update({ defaultSortKey: v as SortKey })}
|
||||
value={defaultSortKey}
|
||||
onChange={(v) => set('filesDefaultSortKey', v as FilesSortKey)}
|
||||
options={[
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'size', label: 'Size' },
|
||||
@@ -174,8 +195,8 @@ export function FilesSettings() {
|
||||
|
||||
<SettingItem label="Sort direction">
|
||||
<RadioGroup
|
||||
value={prefs.defaultSortDir}
|
||||
onChange={(v) => update({ defaultSortDir: v as SortDir })}
|
||||
value={defaultSortDir}
|
||||
onChange={(v) => set('filesDefaultSortDir', v as FilesSortDir)}
|
||||
options={[
|
||||
{ value: 'asc', label: 'Ascending' },
|
||||
{ value: 'desc', label: 'Descending' },
|
||||
@@ -186,25 +207,28 @@ export function FilesSettings() {
|
||||
|
||||
<SettingsSection title="Icons" description="Appearance of file and folder icons.">
|
||||
<SettingItem label="Show icons" description="Display icons for files and folders.">
|
||||
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
|
||||
<ToggleSwitch checked={showIcons} onChange={(v) => set('filesShowIcons', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
|
||||
<ToggleSwitch
|
||||
checked={prefs.coloredIcons}
|
||||
onChange={(v) => update({ coloredIcons: v })}
|
||||
disabled={!prefs.showIcons}
|
||||
checked={coloredIcons}
|
||||
onChange={(v) => set('filesColoredIcons', v)}
|
||||
disabled={!showIcons}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Show thumbnails" description="Generate previews for images.">
|
||||
<ToggleSwitch checked={prefs.showThumbnails} onChange={(v) => update({ showThumbnails: v })} />
|
||||
<ToggleSwitch
|
||||
checked={showThumbnails}
|
||||
onChange={(v) => set('filesShowThumbnails', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Behavior">
|
||||
<SettingItem label="Show hidden files" description="Include dotfiles (.name) in listings.">
|
||||
<ToggleSwitch
|
||||
checked={prefs.showHiddenFiles}
|
||||
onChange={(v) => update({ showHiddenFiles: v })}
|
||||
checked={showHiddenFiles}
|
||||
onChange={(v) => set('filesShowHiddenFiles', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
@@ -212,14 +236,15 @@ export function FilesSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
previewLabel: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
|
||||
previewLabel: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
|
||||
previewBox: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
overflow: 'hidden',
|
||||
minHeight: 160,
|
||||
},
|
||||
@@ -230,10 +255,10 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
borderBottomColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
listHeaderText: { fontSize: 10, fontWeight: '500', color: colors.mutedForeground },
|
||||
listHeaderText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||
listRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -241,10 +266,10 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
listName: { fontSize: 11, color: colors.text, flex: 1 },
|
||||
listMeta: { fontSize: 10, color: colors.mutedForeground, width: 60, textAlign: 'right' },
|
||||
listName: { fontSize: 11, color: c.text, flex: 1 },
|
||||
listMeta: { fontSize: 10, color: c.mutedForeground, width: 60, textAlign: 'right' },
|
||||
gridWrap: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
@@ -258,5 +283,6 @@ const styles = StyleSheet.create({
|
||||
width: 72,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
gridName: { fontSize: 9, color: colors.text, textAlign: 'center' },
|
||||
gridName: { fontSize: 9, color: c.text, textAlign: 'center' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
|
||||
import { SettingsSection, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
@@ -16,11 +18,20 @@ interface Rule {
|
||||
const SAMPLE_RULES: Rule[] = [];
|
||||
|
||||
export function FilterSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
|
||||
const [expandedView, setExpandedView] = useState(false);
|
||||
const [isOpaque] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
@@ -45,7 +56,7 @@ export function FilterSettings() {
|
||||
<Text style={styles.linkText}>Open Sieve Editor</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.resetLink}>
|
||||
<RotateCcw size={12} color={colors.error} />
|
||||
<RotateCcw size={12} color={c.error} />
|
||||
<Text style={styles.resetText}>Reset to visual</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -55,7 +66,7 @@ export function FilterSettings() {
|
||||
|
||||
{rules.length === 0 && !isOpaque && (
|
||||
<View style={styles.emptyState}>
|
||||
<Filter size={40} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
<Text style={styles.emptyText}>No rules yet.</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -65,7 +76,7 @@ export function FilterSettings() {
|
||||
{rules.map((rule) => (
|
||||
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
|
||||
<View style={styles.grip}>
|
||||
<GripVertical size={16} color={colors.mutedForeground} />
|
||||
<GripVertical size={16} color={c.mutedForeground} />
|
||||
</View>
|
||||
|
||||
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
|
||||
@@ -88,7 +99,7 @@ export function FilterSettings() {
|
||||
</View>
|
||||
) : (
|
||||
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
|
||||
<X size={16} color={colors.mutedForeground} />
|
||||
<X size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -100,11 +111,11 @@ export function FilterSettings() {
|
||||
<View style={styles.footer}>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
||||
{!isOpaque && (
|
||||
<Button variant="outline" size="sm" icon={<Plus size={14} color={colors.text} />}>
|
||||
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />}>
|
||||
Add Rule
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" icon={<Code size={14} color={colors.text} />}>
|
||||
<Button variant="outline" size="sm" icon={<Code size={14} color={c.text} />}>
|
||||
Raw Editor
|
||||
</Button>
|
||||
</View>
|
||||
@@ -112,7 +123,7 @@ export function FilterSettings() {
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={styles.expandedToggle}>
|
||||
<Text style={styles.expandedLabel}>Expanded view</Text>
|
||||
<ToggleSwitch checked={expandedView} onChange={setExpandedView} />
|
||||
<ToggleSwitch checked={expandedView} onChange={(v) => update('filtersExpandedView', v)} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -120,7 +131,8 @@ export function FilterSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xl },
|
||||
opaqueWarning: {
|
||||
flexDirection: 'row',
|
||||
@@ -134,11 +146,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
opaqueText: { ...typography.caption, color: '#fde68a' },
|
||||
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
|
||||
linkText: { ...typography.caption, color: colors.primary, fontWeight: '500' },
|
||||
linkText: { ...typography.caption, color: c.primary, fontWeight: '500' },
|
||||
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
resetText: { ...typography.caption, color: colors.error, fontWeight: '500' },
|
||||
resetText: { ...typography.caption, color: c.error, fontWeight: '500' },
|
||||
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
|
||||
emptyText: { ...typography.body, color: colors.mutedForeground },
|
||||
emptyText: { ...typography.body, color: c.mutedForeground },
|
||||
ruleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
@@ -146,12 +158,12 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
ruleDisabled: { opacity: 0.6 },
|
||||
grip: { paddingTop: 2 },
|
||||
ruleName: { ...typography.bodyMedium, color: colors.text },
|
||||
ruleSummary: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
||||
ruleName: { ...typography.bodyMedium, color: c.text },
|
||||
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
iconBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -167,5 +179,6 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
expandedLabel: { ...typography.caption, color: colors.mutedForeground },
|
||||
expandedLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||
Plus, Pencil, Trash2, X,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
@@ -22,10 +36,21 @@ function getIcon(mb: Mailbox) {
|
||||
return Folder;
|
||||
}
|
||||
|
||||
type Editor =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'rename'; mailbox: Mailbox };
|
||||
|
||||
export function FolderSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
||||
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (mailboxes.length === 0) void fetchMailboxes();
|
||||
}, [mailboxes.length, fetchMailboxes]);
|
||||
@@ -39,27 +64,120 @@ export function FolderSettings() {
|
||||
|
||||
const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditor({ kind: 'create' });
|
||||
setDraftName('');
|
||||
};
|
||||
|
||||
const openRename = (mailbox: Mailbox) => {
|
||||
if (mailbox.role) {
|
||||
Alert.alert('Cannot rename', 'System folders cannot be renamed.');
|
||||
return;
|
||||
}
|
||||
setEditor({ kind: 'rename', mailbox });
|
||||
setDraftName(mailbox.name);
|
||||
};
|
||||
|
||||
const closeEditor = () => setEditor(null);
|
||||
|
||||
const saveDraft = async () => {
|
||||
const name = draftName.trim();
|
||||
if (!name) {
|
||||
Alert.alert('Name required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editor?.kind === 'create') {
|
||||
await createMailbox({ name });
|
||||
} else if (editor?.kind === 'rename') {
|
||||
await updateMailbox(editor.mailbox.id, { name });
|
||||
}
|
||||
closeEditor();
|
||||
await fetchMailboxes();
|
||||
} catch (err) {
|
||||
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (mailbox: Mailbox) => {
|
||||
if (mailbox.role) {
|
||||
Alert.alert('Cannot delete', 'System folders cannot be removed.');
|
||||
return;
|
||||
}
|
||||
if ((mailbox.totalEmails ?? 0) > 0) {
|
||||
Alert.alert(
|
||||
'Folder not empty',
|
||||
`"${mailbox.name}" contains ${mailbox.totalEmails} email${mailbox.totalEmails === 1 ? '' : 's'}. Delete anyway?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
'Delete folder',
|
||||
`Permanently delete "${mailbox.name}"?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const performDelete = async (mailbox: Mailbox) => {
|
||||
setBusyId(mailbox.id);
|
||||
try {
|
||||
await deleteMailbox(mailbox.id);
|
||||
await fetchMailboxes();
|
||||
} catch (err) {
|
||||
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection
|
||||
title="Folders"
|
||||
description={`Your mail folders. ${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread.`}
|
||||
description={`${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread. Tap a folder to rename, long-press to delete.`}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={openCreate}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
</View>
|
||||
{mailboxes.length === 0 ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{sorted.map((mb) => {
|
||||
const Icon = getIcon(mb);
|
||||
const editable = !mb.role;
|
||||
return (
|
||||
<View key={mb.id} style={styles.folderRow}>
|
||||
<Pressable
|
||||
key={mb.id}
|
||||
onPress={() => editable && openRename(mb)}
|
||||
onLongPress={() => editable && confirmDelete(mb)}
|
||||
disabled={!editable && busyId !== mb.id}
|
||||
style={({ pressed }) => [
|
||||
styles.folderRow,
|
||||
editable && pressed && styles.folderRowPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.folderLeft}>
|
||||
<Icon
|
||||
size={16}
|
||||
color={mb.role ? colors.primary : colors.mutedForeground}
|
||||
/>
|
||||
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
|
||||
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
|
||||
{mb.role && ROLE_LABEL[mb.role] && (
|
||||
<View style={styles.rolePill}>
|
||||
@@ -74,54 +192,127 @@ export function FolderSettings() {
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.total}>{mb.totalEmails}</Text>
|
||||
{editable && (
|
||||
busyId === mb.id ? (
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
) : (
|
||||
<Pencil size={14} color={c.textMuted} />
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Management"
|
||||
description="Creating, renaming and deleting folders is not yet available in the mobile app. Use the web client."
|
||||
>
|
||||
<SettingItem label="Read-only">
|
||||
<Text style={styles.caption}>Folder list shown above is live.</Text>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
<Modal visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{editor?.kind === 'rename' ? `Rename "${editor.mailbox.name}"` : 'New folder'}
|
||||
</Text>
|
||||
<Pressable onPress={closeEditor} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||
<Text style={styles.fieldLabel}>Folder name</Text>
|
||||
<TextInput
|
||||
value={draftName}
|
||||
onChangeText={setDraftName}
|
||||
placeholder="Receipts"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
{editor?.kind === 'rename' && (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
closeEditor();
|
||||
confirmDelete(editor.mailbox);
|
||||
}}
|
||||
style={styles.deleteRow}
|
||||
>
|
||||
<Trash2 size={14} color={c.error} />
|
||||
<Text style={styles.deleteRowText}>Delete folder</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={() => { void saveDraft(); }}
|
||||
loading={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
loading: { paddingVertical: 40, alignItems: 'center' },
|
||||
folderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
|
||||
folderName: { ...typography.body, color: colors.text, flexShrink: 1 },
|
||||
rolePill: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primaryBg,
|
||||
},
|
||||
rolePillText: { fontSize: 10, fontWeight: '500', color: colors.primary },
|
||||
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
unreadBadge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
unreadText: { fontSize: 10, fontWeight: '500', color: colors.primaryForeground },
|
||||
total: { ...typography.caption, color: colors.mutedForeground, minWidth: 32, textAlign: 'right' },
|
||||
caption: { ...typography.caption, color: colors.mutedForeground },
|
||||
});
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
headerRow: { flexDirection: 'row', justifyContent: 'flex-end', paddingVertical: spacing.sm },
|
||||
loading: { paddingVertical: 40, alignItems: 'center' },
|
||||
folderRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
folderRowPressed: { backgroundColor: c.muted },
|
||||
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
|
||||
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
|
||||
rolePill: {
|
||||
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
|
||||
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
unreadBadge: {
|
||||
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
|
||||
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
|
||||
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
modalSheet: {
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1, borderBottomColor: c.border,
|
||||
},
|
||||
modalTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
|
||||
modalBody: { padding: spacing.lg, gap: spacing.md },
|
||||
fieldLabel: { ...typography.captionMedium, color: c.textSecondary },
|
||||
input: {
|
||||
...typography.body, color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md, paddingVertical: 10,
|
||||
},
|
||||
deleteRow: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
deleteRowText: { ...typography.body, color: c.error },
|
||||
modalActions: {
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
|
||||
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,41 +1,154 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Plus, Trash2, X } from 'lucide-react-native';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, typography, spacing, radius } from '../../theme/tokens';
|
||||
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
createIdentity,
|
||||
deleteIdentity,
|
||||
updateIdentity,
|
||||
} from '../../api/identity';
|
||||
import type { Identity } from '../../api/types';
|
||||
|
||||
type DraftIdentity = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
textSignature: string;
|
||||
mayDelete: boolean;
|
||||
};
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function toDraft(identity: Identity): DraftIdentity {
|
||||
return {
|
||||
id: identity.id,
|
||||
name: identity.name ?? '',
|
||||
email: identity.email,
|
||||
textSignature: identity.textSignature ?? '',
|
||||
mayDelete: identity.mayDelete,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDraft(): DraftIdentity {
|
||||
return { id: '', name: '', email: '', textSignature: '', mayDelete: true };
|
||||
}
|
||||
|
||||
export function IdentitySettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const identities = useSettingsStore((s) => s.identities);
|
||||
const loading = useSettingsStore((s) => s.loading);
|
||||
const error = useSettingsStore((s) => s.error);
|
||||
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
|
||||
|
||||
const [editing, setEditing] = useState<DraftIdentity | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchIdentities();
|
||||
}, [fetchIdentities]);
|
||||
|
||||
const count = identities.length;
|
||||
const countLabel =
|
||||
count === 0 ? 'No identities' : count === 1 ? '1 identity' : `${count} identities`;
|
||||
const openCreate = () => setEditing(emptyDraft());
|
||||
const openEdit = (i: Identity) => setEditing(toDraft(i));
|
||||
const closeEditor = () => setEditing(null);
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (!editing) return;
|
||||
const name = editing.name.trim();
|
||||
const email = editing.email.trim();
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
Alert.alert('Invalid email', 'Enter a valid email address for this identity.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing.id) {
|
||||
// JMAP doesn't allow changing `email` on an existing identity, so we
|
||||
// only PATCH the editable fields. The server will reject email
|
||||
// changes; the form disables that input below to make this obvious.
|
||||
await updateIdentity(editing.id, {
|
||||
name,
|
||||
textSignature: editing.textSignature,
|
||||
});
|
||||
} else {
|
||||
await createIdentity({
|
||||
name,
|
||||
email,
|
||||
textSignature: editing.textSignature || undefined,
|
||||
});
|
||||
}
|
||||
closeEditor();
|
||||
await fetchIdentities();
|
||||
} catch (err) {
|
||||
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (identity: Identity) => {
|
||||
if (!identity.mayDelete) {
|
||||
Alert.alert('Cannot delete', 'The primary identity cannot be removed.');
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
'Delete identity',
|
||||
`Remove "${identity.name || identity.email}"?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setDeletingId(identity.id);
|
||||
try {
|
||||
await deleteIdentity(identity.id);
|
||||
await fetchIdentities();
|
||||
} catch (err) {
|
||||
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Identities"
|
||||
description="Manage sender names, email addresses, and signatures."
|
||||
description="Manage sender names, email addresses, and signatures. Tap a row to edit."
|
||||
>
|
||||
<SettingItem
|
||||
label="Identities"
|
||||
description="Your configured sender identities."
|
||||
>
|
||||
<View style={styles.row}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<Text style={styles.count}>{countLabel}</Text>
|
||||
)}
|
||||
</View>
|
||||
</SettingItem>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.count}>
|
||||
{loading ? 'Loading…' : identities.length === 1 ? '1 identity' : `${identities.length} identities`}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={openCreate}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
disabled={loading}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBox}>
|
||||
@@ -44,61 +157,150 @@ export function IdentitySettings() {
|
||||
)}
|
||||
|
||||
{identities.map((identity) => (
|
||||
<View key={identity.id} style={styles.identityRow}>
|
||||
<Pressable
|
||||
key={identity.id}
|
||||
onPress={() => openEdit(identity)}
|
||||
style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.identityName}>{identity.name || '(no name)'}</Text>
|
||||
<Text style={styles.identityEmail}>{identity.email}</Text>
|
||||
</View>
|
||||
{!identity.mayDelete && (
|
||||
{!identity.mayDelete ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>primary</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => confirmDelete(identity)}
|
||||
hitSlop={8}
|
||||
style={styles.identityDelete}
|
||||
disabled={deletingId === identity.id}
|
||||
>
|
||||
{deletingId === identity.id ? (
|
||||
<ActivityIndicator size="small" color={c.error} />
|
||||
) : (
|
||||
<Trash2 size={16} color={c.error} />
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
<SettingItem
|
||||
label="Sub-addressing"
|
||||
description="Use plus-addressing (you+tag@example.com) to route mail."
|
||||
>
|
||||
<Button variant="outline" size="sm">Learn More</Button>
|
||||
</SettingItem>
|
||||
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{editing?.id ? 'Edit identity' : 'New identity'}
|
||||
</Text>
|
||||
<Pressable onPress={closeEditor} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||
<Text style={styles.fieldLabel}>Display name</Text>
|
||||
<TextInput
|
||||
value={editing?.name ?? ''}
|
||||
onChangeText={(name) => setEditing((d) => (d ? { ...d, name } : d))}
|
||||
placeholder="Jane Doe"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>Email address</Text>
|
||||
<TextInput
|
||||
value={editing?.email ?? ''}
|
||||
onChangeText={(email) => setEditing((d) => (d ? { ...d, email } : d))}
|
||||
placeholder="jane@example.com"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={[styles.input, !!editing?.id && styles.inputDisabled]}
|
||||
editable={!editing?.id}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
{!!editing?.id && (
|
||||
<Text style={styles.hint}>JMAP does not allow changing an identity's email; create a new one instead.</Text>
|
||||
)}
|
||||
<Text style={styles.fieldLabel}>Plain-text signature</Text>
|
||||
<TextInput
|
||||
value={editing?.textSignature ?? ''}
|
||||
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
|
||||
placeholder="-- Jane Doe Bulwark Mail"
|
||||
placeholderTextColor={c.textMuted}
|
||||
multiline
|
||||
style={[styles.input, styles.bodyInput]}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={() => { void saveDraft(); }}
|
||||
loading={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
count: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
errorBox: {
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.error },
|
||||
identityRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
identityName: { ...typography.bodyMedium, color: colors.text },
|
||||
identityEmail: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
||||
badge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: 'rgba(59,130,246,0.15)',
|
||||
},
|
||||
badgeText: { fontSize: 10, fontWeight: '500', color: colors.primary },
|
||||
});
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
count: { ...typography.body, color: c.mutedForeground },
|
||||
errorBox: { padding: spacing.md, borderRadius: radius.sm, backgroundColor: c.errorBg },
|
||||
errorText: { ...typography.caption, color: c.error },
|
||||
identityRow: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: spacing.md,
|
||||
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.sm, backgroundColor: c.muted,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
identityRowPressed: { backgroundColor: c.surfaceHover },
|
||||
identityName: { ...typography.bodyMedium, color: c.text },
|
||||
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
identityDelete: { padding: 6 },
|
||||
badge: {
|
||||
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
|
||||
backgroundColor: 'rgba(59,130,246,0.15)',
|
||||
},
|
||||
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
|
||||
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
modalSheet: {
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1, borderBottomColor: c.border,
|
||||
},
|
||||
modalTitle: { ...typography.h3, color: c.text },
|
||||
modalBody: { padding: spacing.lg, gap: spacing.sm },
|
||||
fieldLabel: { ...typography.captionMedium, color: c.textSecondary, marginTop: spacing.sm },
|
||||
input: {
|
||||
...typography.body, color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md, paddingVertical: 10,
|
||||
},
|
||||
inputDisabled: { opacity: 0.6 },
|
||||
bodyInput: { minHeight: 100, textAlignVertical: 'top' },
|
||||
hint: { ...typography.caption, color: c.mutedForeground },
|
||||
modalActions: {
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
|
||||
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
||||
import { DARK_COLORS } from '../../theme/tokens';
|
||||
|
||||
interface Keyword {
|
||||
id: string;
|
||||
label: string;
|
||||
color: keyof typeof colors.tags;
|
||||
}
|
||||
type Keyword = KeywordDef;
|
||||
|
||||
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
|
||||
|
||||
const DEFAULT_KEYWORDS: Keyword[] = [
|
||||
{ id: 'important', label: 'Important', color: 'red' },
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'personal', label: 'Personal', color: 'green' },
|
||||
{ id: 'todo', label: 'Todo', color: 'amber' },
|
||||
];
|
||||
// Palette keys are theme-agnostic (same names in both palettes), so use DARK_COLORS
|
||||
// at module load. The actual rendered swatch colors come from the active theme via `c.tags[key]`.
|
||||
const PALETTE_KEYS = Object.keys(DARK_COLORS.tags) as (keyof typeof DARK_COLORS.tags)[];
|
||||
|
||||
export function KeywordSettings() {
|
||||
const [keywords, setKeywords] = useState<Keyword[]>(DEFAULT_KEYWORDS);
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const keywords = useKeywordsStore((s) => s.keywords);
|
||||
const addKeyword = useKeywordsStore((s) => s.add);
|
||||
const updateKeyword = useKeywordsStore((s) => s.update);
|
||||
const removeKeyword = useKeywordsStore((s) => s.remove);
|
||||
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
|
||||
const hydrated = useKeywordsStore((s) => s.hydrated);
|
||||
const hydrate = useKeywordsStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const saveKeyword = (kw: Keyword, editing: boolean) => {
|
||||
if (editing) {
|
||||
setKeywords((ks) => ks.map((k) => k.id === editingId ? kw : k));
|
||||
if (editing && editingId) {
|
||||
const { id, ...patch } = kw;
|
||||
updateKeyword(editingId, patch);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
setKeywords((ks) => [...ks, kw]);
|
||||
addKeyword(kw);
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKeyword = (id: string) => {
|
||||
setKeywords((ks) => ks.filter((k) => k.id !== id));
|
||||
};
|
||||
|
||||
const resetDefaults = () => {
|
||||
setKeywords(DEFAULT_KEYWORDS);
|
||||
removeKeyword(id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,7 +59,7 @@ export function KeywordSettings() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
const palette = colors.tags[kw.color];
|
||||
const palette = c.tags[kw.color];
|
||||
return (
|
||||
<View key={kw.id} style={styles.kwRow}>
|
||||
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
|
||||
@@ -65,10 +67,10 @@ export function KeywordSettings() {
|
||||
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 2 }}>
|
||||
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
|
||||
<Pencil size={14} color={colors.mutedForeground} />
|
||||
<Pencil size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
|
||||
<Trash2 size={14} color={colors.mutedForeground} />
|
||||
<Trash2 size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -86,11 +88,11 @@ export function KeywordSettings() {
|
||||
{!isAdding && editingId === null && (
|
||||
<View style={styles.bottomActions}>
|
||||
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
|
||||
<Plus size={14} color={colors.mutedForeground} />
|
||||
<Plus size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.outlineBtnText}>Add keyword</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
|
||||
<RotateCcw size={14} color={colors.mutedForeground} />
|
||||
<RotateCcw size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.outlineBtnText}>Reset defaults</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -108,8 +110,10 @@ interface KeywordFormProps {
|
||||
}
|
||||
|
||||
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
const [color, setColor] = useState<keyof typeof colors.tags>(initial?.color ?? 'blue');
|
||||
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
|
||||
|
||||
const normalizedId = label
|
||||
.trim()
|
||||
@@ -134,7 +138,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
placeholder="e.g. Important"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.input}
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
@@ -146,7 +150,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
||||
<Text style={styles.formLabel}>Color</Text>
|
||||
<View style={styles.palette}>
|
||||
{PALETTE_KEYS.map((key) => {
|
||||
const p = colors.tags[key];
|
||||
const p = c.tags[key];
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
@@ -164,7 +168,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
||||
|
||||
<View style={styles.formActions}>
|
||||
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
|
||||
<X size={14} color={colors.text} />
|
||||
<X size={14} color={c.text} />
|
||||
<Text style={styles.cancelFormText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -172,7 +176,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
||||
onPress={handleSave}
|
||||
disabled={!isValid}
|
||||
>
|
||||
<Check size={14} color={colors.primaryForeground} />
|
||||
<Check size={14} color={c.primaryForeground} />
|
||||
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -180,7 +184,8 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
kwRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -188,12 +193,12 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
kwDot: { width: 20, height: 20, borderRadius: 10 },
|
||||
kwLabel: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
||||
kwId: { fontSize: 10, color: colors.mutedForeground, fontFamily: 'monospace' },
|
||||
kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
kwId: { fontSize: 10, color: c.mutedForeground, fontFamily: 'monospace' },
|
||||
iconBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -215,34 +220,34 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
outlineBtnText: { ...typography.caption, color: colors.mutedForeground },
|
||||
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primaryBorder,
|
||||
backgroundColor: colors.accent,
|
||||
borderColor: c.primaryBorder,
|
||||
backgroundColor: c.accent,
|
||||
},
|
||||
formLabel: { ...typography.caption, color: colors.mutedForeground, marginBottom: 4 },
|
||||
formLabel: { ...typography.caption, color: c.mutedForeground, marginBottom: 4 },
|
||||
input: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
borderColor: c.border,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.error, marginTop: 4 },
|
||||
errorText: { ...typography.caption, color: c.error, marginTop: 4 },
|
||||
palette: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
colorSwatch: { width: 24, height: 24, borderRadius: 12 },
|
||||
colorSwatchSelected: {
|
||||
borderWidth: 2,
|
||||
borderColor: colors.text,
|
||||
borderColor: c.text,
|
||||
},
|
||||
formActions: {
|
||||
flexDirection: 'row',
|
||||
@@ -257,9 +262,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
cancelFormText: { ...typography.caption, color: colors.text },
|
||||
cancelFormText: { ...typography.caption, color: c.text },
|
||||
saveBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -267,7 +272,8 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
saveBtnText: { ...typography.caption, color: colors.primaryForeground, fontWeight: '500' },
|
||||
saveBtnText: { ...typography.caption, color: c.primaryForeground, fontWeight: '500' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const override = useLocaleStore((s) => s.override);
|
||||
const setOverride = useLocaleStore((s) => s.setOverride);
|
||||
const hydrated = useLocaleStore((s) => s.hydrated);
|
||||
const hydrate = useLocaleStore((s) => s.hydrate);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const deviceCode = detectDeviceLocale();
|
||||
const selected: LocaleCode | 'system' = override ?? 'system';
|
||||
|
||||
const renderRow = (code: LocaleCode | 'system', label: string, sublabel?: string) => {
|
||||
const active = selected === code;
|
||||
return (
|
||||
<Pressable
|
||||
key={code}
|
||||
onPress={() => setOverride(code === 'system' ? null : (code as LocaleCode))}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{sublabel ? <Text style={styles.sublabel}>{sublabel}</Text> : null}
|
||||
</View>
|
||||
{active ? <Check size={16} color={c.primary} /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const deviceLabel =
|
||||
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.appearance.language.label', 'Language')}
|
||||
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.appearance.language.label', 'Language')}
|
||||
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||
noBorder
|
||||
/>
|
||||
<View style={styles.list}>
|
||||
{renderRow('system', `System default (${deviceLabel})`)}
|
||||
<View style={styles.divider} />
|
||||
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: c.background },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.muted },
|
||||
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
|
||||
label: { ...typography.body, color: c.text },
|
||||
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
|
||||
import { SettingsSection, RadioGroup } from './settings-section';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
|
||||
|
||||
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'archive', label: 'Archive' },
|
||||
{ value: 'delete', label: 'Delete' },
|
||||
{ value: 'spam', label: 'Spam' },
|
||||
{ value: 'read', label: 'Read/Unread' },
|
||||
{ value: 'star', label: 'Star' },
|
||||
{ value: 'pin', label: 'Pin' },
|
||||
{ value: 'move', label: 'Move to folder' },
|
||||
];
|
||||
|
||||
const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
|
||||
{ value: 'instant', label: 'Instant (swipe = action)' },
|
||||
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
|
||||
];
|
||||
|
||||
export function LayoutSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
|
||||
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
||||
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
<Text style={styles.rowLabel}>Swipe behavior</Text>
|
||||
<Text style={styles.rowDescription}>
|
||||
Pick instant (swipe past the threshold to fire the action) or reveal
|
||||
(swipe to expose an action band, then tap to confirm).
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={swipeMode}
|
||||
onChange={(v) => setSwipeMode(v as SwipeMode)}
|
||||
options={SWIPE_MODE_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<ArrowRight size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
Action when you drag a row from its left edge towards the right.
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={swipeRightAction}
|
||||
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
|
||||
options={SWIPE_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<ArrowLeft size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
Action when you drag a row from its right edge towards the left.
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={swipeLeftAction}
|
||||
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
|
||||
options={SWIPE_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
rowLabel: { ...typography.bodyMedium, color: c.text },
|
||||
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
@@ -10,17 +10,20 @@ import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useSettingsStore, type NotificationSound } from '../../stores/settings-store';
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
getStoredRelayBaseUrl,
|
||||
readPushAccountIds,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../../lib/push-notifications';
|
||||
|
||||
const SOUNDS = [
|
||||
const SOUNDS: { value: NotificationSound; label: string }[] = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'chime', label: 'Chime' },
|
||||
{ value: 'ping', label: 'Ping' },
|
||||
@@ -35,20 +38,30 @@ type PushStatus =
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export function NotificationSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
|
||||
const [sound, setSound] = useState('default');
|
||||
const [emailEnabled, setEmailEnabled] = useState(true);
|
||||
const [emailSound, setEmailSound] = useState(true);
|
||||
const [calEnabled, setCalEnabled] = useState(true);
|
||||
const [calSound, setCalSound] = useState(true);
|
||||
const [invitationParsing, setInvitationParsing] = useState(true);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const sound = useSettingsStore((s) => s.notificationSoundChoice);
|
||||
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
|
||||
const emailSound = useSettingsStore((s) => s.emailNotificationSound);
|
||||
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
||||
const calSound = useSettingsStore((s) => s.calendarNotificationSound);
|
||||
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
|
||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||
const [hasSaved, setHasSaved] = useState(false);
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const stored = await getStoredRelayBaseUrl();
|
||||
@@ -95,10 +108,20 @@ export function NotificationSettings() {
|
||||
setConfirmDisable(false);
|
||||
setPushStatus({ kind: 'busy', message: 'Disabling…' });
|
||||
try {
|
||||
await teardownPushNotifications();
|
||||
await setStoredRelayBaseUrl(null);
|
||||
setRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
setHasSaved(false);
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId);
|
||||
}
|
||||
// The relay base URL is a device-wide setting shared with any other
|
||||
// accounts that are still using push. Only clear it (and reset the UI
|
||||
// to "not configured") when no accounts are using push any more —
|
||||
// otherwise the remaining accounts would lose their relay URL and
|
||||
// their next setup attempt would no-op.
|
||||
const remaining = await readPushAccountIds();
|
||||
if (remaining.length === 0) {
|
||||
await setStoredRelayBaseUrl(null);
|
||||
setRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
setHasSaved(false);
|
||||
}
|
||||
setPushStatus({ kind: 'idle' });
|
||||
} catch (error) {
|
||||
setPushStatus({
|
||||
@@ -120,26 +143,26 @@ export function NotificationSettings() {
|
||||
<View style={styles.statusRow}>
|
||||
{pushStatus.kind === 'busy' && (
|
||||
<>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<Text style={styles.statusText}>{pushStatus.message}</Text>
|
||||
</>
|
||||
)}
|
||||
{pushStatus.kind === 'enabled' && (
|
||||
<>
|
||||
<CheckCircle2 size={14} color={colors.success} />
|
||||
<Text style={[styles.statusText, { color: colors.success }]}>Active</Text>
|
||||
<CheckCircle2 size={14} color={c.success} />
|
||||
<Text style={[styles.statusText, { color: c.success }]}>Active</Text>
|
||||
</>
|
||||
)}
|
||||
{pushStatus.kind === 'error' && (
|
||||
<>
|
||||
<XCircle size={14} color={colors.error} />
|
||||
<Text style={[styles.statusText, { color: colors.error }]}>
|
||||
<XCircle size={14} color={c.error} />
|
||||
<Text style={[styles.statusText, { color: c.error }]}>
|
||||
{pushStatus.message}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{pushStatus.kind === 'idle' && (
|
||||
<Text style={[styles.statusText, { color: colors.mutedForeground }]}>
|
||||
<Text style={[styles.statusText, { color: c.mutedForeground }]}>
|
||||
Not configured
|
||||
</Text>
|
||||
)}
|
||||
@@ -152,7 +175,7 @@ export function NotificationSettings() {
|
||||
value={relayUrl}
|
||||
onChangeText={setRelayUrl}
|
||||
placeholder={DEFAULT_RELAY_BASE_URL}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
@@ -183,11 +206,11 @@ export function NotificationSettings() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={<Volume2 size={16} color={colors.text} />}
|
||||
icon={<Volume2 size={16} color={c.text} />}
|
||||
/>
|
||||
<Select
|
||||
value={sound}
|
||||
onChange={setSound}
|
||||
onChange={(v) => update('notificationSoundChoice', v as NotificationSound)}
|
||||
options={SOUNDS}
|
||||
/>
|
||||
</View>
|
||||
@@ -196,22 +219,33 @@ export function NotificationSettings() {
|
||||
|
||||
<SettingsSection title="Email" description="Notifications for incoming email.">
|
||||
<SettingItem label="Email Notifications" description="Show desktop alerts for new email.">
|
||||
<ToggleSwitch checked={emailEnabled} onChange={setEmailEnabled} />
|
||||
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Email Sound" description="Play a sound when new email arrives.">
|
||||
<ToggleSwitch checked={emailSound} onChange={setEmailSound} disabled={!emailEnabled} />
|
||||
<ToggleSwitch
|
||||
checked={emailSound}
|
||||
onChange={(v) => update('emailNotificationSound', v)}
|
||||
disabled={!emailEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Calendar" description="Notifications for events and invitations.">
|
||||
<SettingItem label="Calendar Notifications" description="Show alerts for upcoming events.">
|
||||
<ToggleSwitch checked={calEnabled} onChange={setCalEnabled} />
|
||||
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Calendar Sound" description="Play a sound for calendar alerts.">
|
||||
<ToggleSwitch checked={calSound} onChange={setCalSound} disabled={!calEnabled} />
|
||||
<ToggleSwitch
|
||||
checked={calSound}
|
||||
onChange={(v) => update('calendarNotificationSound', v)}
|
||||
disabled={!calEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Invitation Parsing" description="Automatically detect and parse event invitations.">
|
||||
<ToggleSwitch checked={invitationParsing} onChange={setInvitationParsing} />
|
||||
<ToggleSwitch
|
||||
checked={invitationParsing}
|
||||
onChange={(v) => update('calendarInvitationParsingEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -229,12 +263,13 @@ export function NotificationSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
pushCard: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
@@ -247,18 +282,18 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
fieldLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
fieldDescription: {
|
||||
...typography.caption,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
urlInput: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
@@ -266,6 +301,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: 4,
|
||||
},
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
|
||||
statusText: { ...typography.caption, color: colors.text },
|
||||
statusText: { ...typography.caption, color: c.text },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.md },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
|
||||
import { Upload, Puzzle, AlertTriangle, Lock, Trash2 } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
interface Plugin {
|
||||
id: string;
|
||||
@@ -19,30 +21,56 @@ interface Plugin {
|
||||
forceEnabled?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
|
||||
installed: { bg: colors.muted, fg: colors.mutedForeground },
|
||||
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
|
||||
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
|
||||
disabled: { bg: colors.muted, fg: colors.mutedForeground },
|
||||
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
|
||||
};
|
||||
function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; fg: string } {
|
||||
switch (status) {
|
||||
case 'installed': return { bg: c.muted, fg: c.mutedForeground };
|
||||
case 'enabled': return { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' };
|
||||
case 'running': return { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' };
|
||||
case 'disabled': return { bg: c.muted, fg: c.mutedForeground };
|
||||
case 'error': return { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' };
|
||||
}
|
||||
}
|
||||
|
||||
export function PluginsSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const enabledMap = useSettingsStore((s) => s.pluginEnabled);
|
||||
const setPluginEnabled = useSettingsStore((s) => s.setPluginEnabled);
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const togglePlugin = (id: string) => {
|
||||
setPlugins((ps) => ps.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const enabled = !p.enabled;
|
||||
return { ...p, enabled, status: enabled ? 'enabled' : 'disabled' };
|
||||
}));
|
||||
const current = plugins.find((p) => p.id === id);
|
||||
if (!current) return;
|
||||
const next = !current.enabled;
|
||||
setPluginEnabled(id, next);
|
||||
setPlugins((ps) => ps.map((p) =>
|
||||
p.id === id ? { ...p, enabled: next, status: next ? 'enabled' : 'disabled' } : p,
|
||||
));
|
||||
};
|
||||
|
||||
const uninstall = (id: string) => {
|
||||
setPlugins((ps) => ps.filter((p) => p.id !== id));
|
||||
};
|
||||
|
||||
// When plugins are populated from a real source later, hydrate their enable
|
||||
// state from the persisted map.
|
||||
useEffect(() => {
|
||||
if (plugins.length === 0) return;
|
||||
setPlugins((ps) => ps.map((p) => {
|
||||
const stored = enabledMap[p.id];
|
||||
if (stored === undefined || stored === p.enabled) return p;
|
||||
return { ...p, enabled: stored, status: stored ? 'enabled' : 'disabled' };
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabledMap, plugins.length]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Plugins"
|
||||
@@ -52,7 +80,7 @@ export function PluginsSettings() {
|
||||
>
|
||||
{plugins.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Puzzle size={48} color={colors.mutedForeground} style={{ opacity: 0.3 }} />
|
||||
<Puzzle size={48} color={c.mutedForeground} style={{ opacity: 0.3 }} />
|
||||
<Text style={styles.emptyTitle}>No plugins installed</Text>
|
||||
<Text style={styles.emptyDesc}>Upload a plugin .zip file to get started.</Text>
|
||||
</View>
|
||||
@@ -60,13 +88,13 @@ export function PluginsSettings() {
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{plugins.map((plugin) => {
|
||||
const expanded = expandedId === plugin.id;
|
||||
const status = STATUS_STYLE[plugin.status];
|
||||
const status = statusStyle(c, plugin.status);
|
||||
return (
|
||||
<View
|
||||
key={plugin.id}
|
||||
style={[
|
||||
styles.card,
|
||||
plugin.status === 'error' && { borderColor: colors.errorBorder },
|
||||
plugin.status === 'error' && { borderColor: c.errorBorder },
|
||||
]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
@@ -107,7 +135,7 @@ export function PluginsSettings() {
|
||||
|
||||
{plugin.error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={14} color={colors.error} />
|
||||
<AlertTriangle size={14} color={c.error} />
|
||||
<Text style={styles.errorText}>{plugin.error}</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -145,7 +173,7 @@ export function PluginsSettings() {
|
||||
)}
|
||||
|
||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
|
||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||
Upload .zip
|
||||
</Button>
|
||||
</SettingItem>
|
||||
@@ -153,18 +181,19 @@ export function PluginsSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
empty: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.body, color: colors.mutedForeground },
|
||||
emptyDesc: { ...typography.caption, color: colors.mutedForeground },
|
||||
emptyTitle: { ...typography.body, color: c.mutedForeground },
|
||||
emptyDesc: { ...typography.caption, color: c.mutedForeground },
|
||||
card: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -173,7 +202,7 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.md,
|
||||
},
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
pluginName: { ...typography.bodyMedium, color: colors.text },
|
||||
pluginName: { ...typography.bodyMedium, color: c.text },
|
||||
statusPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full },
|
||||
statusText: { fontSize: 10, fontWeight: '500' },
|
||||
forcedPill: {
|
||||
@@ -187,38 +216,39 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
forcedText: { fontSize: 10, fontWeight: '500', color: '#fbbf24' },
|
||||
metaRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 2 },
|
||||
meta: { ...typography.caption, color: colors.mutedForeground },
|
||||
metaDot: { ...typography.caption, color: colors.mutedForeground, opacity: 0.6 },
|
||||
meta: { ...typography.caption, color: c.mutedForeground },
|
||||
metaDot: { ...typography.caption, color: c.mutedForeground, opacity: 0.6 },
|
||||
cardBody: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
borderTopColor: c.border,
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
description: { ...typography.caption, color: colors.mutedForeground },
|
||||
description: { ...typography.caption, color: c.mutedForeground },
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
backgroundColor: c.errorBg,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.error, flex: 1 },
|
||||
sectionLabel: { ...typography.captionMedium, color: colors.text, marginBottom: 4 },
|
||||
errorText: { ...typography.caption, color: c.error, flex: 1 },
|
||||
sectionLabel: { ...typography.captionMedium, color: c.text, marginBottom: 4 },
|
||||
permWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
|
||||
permPill: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
permText: { fontSize: 10, color: colors.mutedForeground },
|
||||
permText: { fontSize: 10, color: c.mutedForeground },
|
||||
uninstallRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user