mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a76b22d86 | ||
|
|
b254573237 | ||
|
|
2409a51952 | ||
|
|
37e1b93c48 | ||
|
|
a656a6b56a | ||
|
|
20cdc282c8 | ||
|
|
cec98d89db | ||
|
|
8fa8597e9f | ||
|
|
57f15f6f2e | ||
|
|
cd3fdfa217 | ||
|
|
f8064d4544 | ||
|
|
a8bb83ffe0 | ||
|
|
c37256c54e | ||
|
|
e1d318ebc1 | ||
|
|
2090480bf4 | ||
|
|
329c8a2f0b | ||
|
|
49d1fc62a8 | ||
|
|
d5a336519a | ||
|
|
9c7654acb2 | ||
|
|
346a71de84 | ||
|
|
9a278f1be3 | ||
|
|
cf3f919fa2 | ||
|
|
792b028b7a | ||
|
|
ac84f13b22 |
@@ -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,
|
||||
teardownPushNotifications,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
@@ -36,9 +38,11 @@ import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
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 { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { useColors } from './src/theme/colors';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
@@ -54,32 +58,32 @@ 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;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<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,
|
||||
@@ -97,8 +101,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,
|
||||
@@ -162,6 +166,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
|
||||
@@ -177,6 +190,7 @@ export default function App() {
|
||||
React.useEffect(() => {
|
||||
void useSettingsStore.getState().hydrate();
|
||||
void useLocaleStore.getState().hydrate();
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -227,12 +241,21 @@ 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 the active subscription so the
|
||||
// FirebaseMessagingService stops receiving pings for this device.
|
||||
const emailNotificationsEnabled = useSettingsStore(
|
||||
(s) => s.emailNotificationsEnabled,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
let cancelled = false;
|
||||
const doSetup = async () => {
|
||||
if (!emailNotificationsEnabled) {
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl) return;
|
||||
try {
|
||||
@@ -258,7 +281,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
@@ -307,7 +330,7 @@ export default function App() {
|
||||
if (!hasRestoredSession && !hasPersistedAccount) {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<LoadingScreen message="Loading..." />
|
||||
</>
|
||||
);
|
||||
@@ -316,7 +339,7 @@ export default function App() {
|
||||
if (hasRestoredSession && !isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<LoginScreen />
|
||||
</>
|
||||
);
|
||||
@@ -324,7 +347,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} />
|
||||
@@ -372,12 +395,10 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background,
|
||||
gap: spacing.md,
|
||||
},
|
||||
loadingText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -450,10 +450,15 @@
|
||||
"send": "Send",
|
||||
"cancel": "Cancel",
|
||||
"attach": "Attach",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Discard",
|
||||
"discard_draft_title": "Discard draft?",
|
||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||
"saving": "Saving...",
|
||||
"sending": "Sending...",
|
||||
"add_link": "Add link",
|
||||
"link_url_prompt": "Enter the URL",
|
||||
"draft_saved": "Draft saved",
|
||||
"save_failed": "Failed to save",
|
||||
"to_placeholder": "Recipient email addresses",
|
||||
|
||||
Generated
+12
-2
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.18",
|
||||
"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",
|
||||
@@ -2793,6 +2794,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",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.18",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -16,6 +16,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
+42
-11
@@ -1,3 +1,4 @@
|
||||
import { File } from 'expo-file-system';
|
||||
import { jmapClient } from './jmap-client';
|
||||
|
||||
export async function uploadBlob(
|
||||
@@ -7,26 +8,56 @@ 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, {
|
||||
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');
|
||||
}
|
||||
|
||||
export function getDownloadUrl(
|
||||
@@ -38,8 +69,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'));
|
||||
}
|
||||
|
||||
+228
-12
@@ -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,67 @@ export async function getMailboxes(): Promise<Mailbox[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
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}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryEmails(
|
||||
mailboxId: string,
|
||||
options?: {
|
||||
@@ -111,6 +172,144 @@ export async function moveEmail(
|
||||
]);
|
||||
}
|
||||
|
||||
// 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 +363,15 @@ 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 async function sendEmail(
|
||||
email: {
|
||||
from: EmailAddress[];
|
||||
@@ -173,7 +381,7 @@ export async function sendEmail(
|
||||
subject: string;
|
||||
htmlBody?: string;
|
||||
textBody?: string;
|
||||
attachments?: Array<{ blobId: string; type: string; name: string }>;
|
||||
attachments?: OutgoingAttachment[];
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
},
|
||||
@@ -191,21 +399,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) {
|
||||
|
||||
+18
-9
@@ -53,7 +53,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}`;
|
||||
@@ -161,17 +164,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 +186,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 {
|
||||
|
||||
+39
-13
@@ -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) => {
|
||||
|
||||
@@ -191,6 +191,8 @@ export interface ContactAddress {
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
fullAddress?: string;
|
||||
coordinates?: string;
|
||||
timeZone?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
pref?: number;
|
||||
@@ -276,6 +278,23 @@ export interface ContactLink {
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactCryptoKey {
|
||||
uri: string;
|
||||
mediaType?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
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 +317,13 @@ export interface ContactCard {
|
||||
media?: Record<string, ContactMedia>;
|
||||
relatedTo?: Record<string, ContactRelation>;
|
||||
links?: Record<string, ContactLink>;
|
||||
cryptoKeys?: Record<string, ContactCryptoKey>;
|
||||
keywords?: Record<string, boolean>;
|
||||
members?: Record<string, boolean>;
|
||||
speakToAs?: ContactSpeakToAs;
|
||||
calendarUri?: string;
|
||||
schedulingUri?: string;
|
||||
freeBusyUri?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
+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,33 @@ 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 resolvedTheme = useResolvedTheme();
|
||||
// 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 +168,10 @@ 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)
|
||||
|| (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 +244,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);
|
||||
@@ -247,13 +281,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}
|
||||
@@ -308,7 +342,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 +352,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 +364,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bannerButtonText: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
webContainer: {
|
||||
width: '100%',
|
||||
@@ -343,4 +378,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,461 @@
|
||||
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();
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, url); } 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') === url) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : url;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = url.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:*']}
|
||||
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.
|
||||
const url = request.url;
|
||||
return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:');
|
||||
}}
|
||||
/>
|
||||
</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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
||||
} 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 +50,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 +94,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 +114,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 +141,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 +224,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 +247,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 +277,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 +294,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 +333,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 +374,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 +392,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 +404,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 +417,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>
|
||||
@@ -420,14 +430,14 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
{/* 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 +453,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 +475,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)',
|
||||
@@ -478,7 +489,7 @@ const styles = StyleSheet.create({
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.border,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
drawerSafe: { flex: 1 },
|
||||
|
||||
@@ -490,7 +501,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 +517,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,11 +526,11 @@ 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: {
|
||||
@@ -529,7 +540,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#1f1f1f',
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
overflow: 'hidden',
|
||||
// Elevation / shadow
|
||||
shadowColor: '#000',
|
||||
@@ -566,14 +577,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 +597,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 +618,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 +638,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 +661,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sectionHeaderText: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
sectionSettings: { padding: 4 },
|
||||
|
||||
empty: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
@@ -670,10 +681,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 +703,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 +718,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,
|
||||
},
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+245
-74
@@ -1,28 +1,44 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, typography } from '../theme/tokens';
|
||||
import type { SwipeAction } from '../stores/settings-store';
|
||||
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). */
|
||||
/** 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). */
|
||||
/** 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 };
|
||||
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;
|
||||
}
|
||||
|
||||
const ACTIVATION_THRESHOLD = 0.4; // fraction of row width
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor to claim the gesture
|
||||
// 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' },
|
||||
@@ -30,116 +46,271 @@ const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg:
|
||||
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 }): string {
|
||||
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,
|
||||
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;
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') 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 max = widthRef.current;
|
||||
const clamped = Math.max(-max, Math.min(max, g.dx));
|
||||
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) => {
|
||||
const threshold = widthRef.current * ACTIVATION_THRESHOLD;
|
||||
const action = g.dx > 0 ? rightAction : leftAction;
|
||||
const triggered = Math.abs(g.dx) > threshold && action !== 'none';
|
||||
if (triggered) {
|
||||
Animated.timing(dx, {
|
||||
toValue: g.dx > 0 ? widthRef.current : -widthRef.current,
|
||||
duration: 160,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onAction(action);
|
||||
dx.setValue(0);
|
||||
});
|
||||
} else {
|
||||
Animated.spring(dx, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
speed: 24,
|
||||
bounciness: 4,
|
||||
}).start();
|
||||
}
|
||||
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: () => {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
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 renderBand = (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 (
|
||||
<View
|
||||
style={[
|
||||
styles.band,
|
||||
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
|
||||
{ backgroundColor: meta.bg },
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<View style={styles.bandInner}>
|
||||
<Icon size={20} color="#fff" />
|
||||
<Text style={styles.bandLabel}>{label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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}>
|
||||
{renderBand(rightAction, 'left')}
|
||||
{renderBand(leftAction, 'right')}
|
||||
<Animated.View style={{ transform: [{ translateX: dx }] }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { position: 'relative' },
|
||||
band: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
bandInner: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
|
||||
});
|
||||
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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Check, X } 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 {
|
||||
@@ -31,21 +33,25 @@ export function CalendarSidebarDrawer({
|
||||
onToggle,
|
||||
onClose,
|
||||
}: 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 +59,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 +95,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>
|
||||
@@ -134,15 +140,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 +160,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 +177,8 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
@@ -184,7 +193,7 @@ const styles = StyleSheet.create({
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.border,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
drawerSafe: { flex: 1 },
|
||||
|
||||
@@ -195,7 +204,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
headerClose: {
|
||||
width: 36,
|
||||
@@ -204,14 +213,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 +228,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 +240,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 +249,7 @@ 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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,12 +26,14 @@ import {
|
||||
} 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 { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
@@ -106,9 +108,16 @@ export function EventDetailSheet({
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}: EventDetailSheetProps) {
|
||||
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 visible = !!event;
|
||||
const dragHandlers = useSheetDrag({
|
||||
slideY,
|
||||
closedY: Dimensions.get('window').height,
|
||||
onClose,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -166,17 +175,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,29 +197,29 @@ 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} />
|
||||
{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 && (
|
||||
<DetailRow
|
||||
icon={<Bell size={16} color={colors.textMuted} />}
|
||||
icon={<Bell size={16} color={c.textMuted} />}
|
||||
text={alert}
|
||||
/>
|
||||
)}
|
||||
{calendar && (
|
||||
<DetailRow
|
||||
icon={<CalendarIcon size={16} color={colors.textMuted} />}
|
||||
icon={<CalendarIcon size={16} color={c.textMuted} />}
|
||||
text={calendar.name}
|
||||
/>
|
||||
)}
|
||||
@@ -214,7 +227,7 @@ export function EventDetailSheet({
|
||||
{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 +238,7 @@ export function EventDetailSheet({
|
||||
style={[
|
||||
styles.participantDot,
|
||||
{
|
||||
backgroundColor: statusColor(p.participationStatus),
|
||||
backgroundColor: statusColor(c, p.participationStatus),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -244,21 +257,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 +293,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 +316,8 @@ function ActionButton({
|
||||
onPress: () => void;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -317,16 +334,16 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status?: string): string {
|
||||
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 +362,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 +375,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 +403,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 +415,7 @@ 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 },
|
||||
|
||||
participantsBlock: {
|
||||
marginTop: spacing.sm,
|
||||
@@ -406,7 +427,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 +435,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 +453,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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
} 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 { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildAllDayDuration,
|
||||
getCalendarColor,
|
||||
@@ -150,6 +151,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('');
|
||||
@@ -264,7 +267,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 +290,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 +343,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 +395,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,7 +423,7 @@ export function EventModal({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<Bell size={16} color={colors.textMuted} />} label="Reminder">
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => setAlertOpen((v) => !v)}
|
||||
@@ -449,21 +452,21 @@ export function EventModal({
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
icon={<MapPin size={16} color={colors.textMuted} />}
|
||||
icon={<MapPin size={16} color={c.textMuted} />}
|
||||
label="Description"
|
||||
>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Notes, location, link…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
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 +483,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 +563,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 +576,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 +595,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 +620,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,
|
||||
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 },
|
||||
|
||||
popover: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
@@ -637,10 +643,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 +655,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 +666,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 +687,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 +702,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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
@@ -222,7 +230,7 @@ const styles = StyleSheet.create({
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import Constants from 'expo-constants';
|
||||
import { ExternalLink } 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';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
@@ -18,6 +19,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);
|
||||
@@ -64,7 +67,7 @@ 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>
|
||||
@@ -122,14 +125,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 +144,31 @@ 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'luc
|
||||
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';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
@@ -19,6 +20,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('');
|
||||
@@ -42,9 +45,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 +64,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 +82,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 +96,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 +107,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 +122,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 +132,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 +142,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 +169,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
sessionRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -173,18 +177,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,
|
||||
@@ -193,3 +197,4 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } 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 { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
|
||||
|
||||
type Theme = ThemeMode;
|
||||
@@ -20,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}>
|
||||
@@ -46,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}
|
||||
>
|
||||
@@ -57,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}
|
||||
>
|
||||
@@ -76,6 +79,8 @@ function DensityPreview({ density }: { density: Density }) {
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
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);
|
||||
@@ -146,24 +151,25 @@ export function AppearanceSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -177,7 +183,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
densityTime: {
|
||||
fontSize: 10,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
densitySubject: {
|
||||
...typography.caption,
|
||||
@@ -188,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>
|
||||
|
||||
@@ -3,10 +3,13 @@ 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 { 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';
|
||||
|
||||
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);
|
||||
@@ -68,7 +71,7 @@ export function ComposingSettings() {
|
||||
<View key={kw} style={styles.chip}>
|
||||
<Text style={styles.chipText}>{kw}</Text>
|
||||
<Pressable onPress={() => removeKeyword(kw)} hitSlop={6}>
|
||||
<X size={12} color={colors.textSecondary} />
|
||||
<X size={12} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
@@ -79,7 +82,7 @@ export function ComposingSettings() {
|
||||
value={newKeyword}
|
||||
onChangeText={setNewKeyword}
|
||||
placeholder="e.g. attached"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
@@ -91,7 +94,7 @@ export function ComposingSettings() {
|
||||
size="sm"
|
||||
onPress={addKeyword}
|
||||
disabled={!newKeyword.trim()}
|
||||
icon={<Plus size={14} color={colors.primaryForeground} />}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
@@ -102,10 +105,11 @@ export function ComposingSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
|
||||
subLabel: { ...typography.bodyMedium, color: colors.text },
|
||||
subDescription: { ...typography.caption, color: colors.mutedForeground },
|
||||
subLabel: { ...typography.bodyMedium, color: c.text },
|
||||
subDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
@@ -114,19 +118,20 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
chipText: { ...typography.caption, color: colors.text },
|
||||
chipText: { ...typography.caption, color: c.text },
|
||||
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { contactsToVCard } from '../../lib/vcard';
|
||||
import { isGroup } from '../../lib/contact-utils';
|
||||
import { colors, typography } from '../../theme/tokens';
|
||||
import { typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
export function ContactsSettings() {
|
||||
const c = useColors();
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
@@ -67,7 +69,7 @@ export function ContactsSettings() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Download size={14} color={colors.text} />}
|
||||
icon={<Download size={14} color={c.text} />}
|
||||
onPress={() => { void handleExport(); }}
|
||||
disabled={exportable.length === 0 || exporting}
|
||||
loading={exporting}
|
||||
@@ -80,7 +82,7 @@ export function ContactsSettings() {
|
||||
label="Import contacts"
|
||||
description="Importing vCard files is currently available on the web client only."
|
||||
>
|
||||
<Text style={[typography.caption, { color: colors.textMuted }]}>Web only</Text>
|
||||
<Text style={[typography.caption, { color: c.textMuted }]}>Web only</Text>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
import { ChevronRight, X, Plus, Trash2 } from 'lucide-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 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);
|
||||
@@ -64,7 +67,7 @@ export function ContentSendersSettings() {
|
||||
style={({ pressed }) => [styles.trustedButton, pressed && styles.trustedButtonPressed]}
|
||||
>
|
||||
<Text style={styles.trustedButtonText}>{trustedLabel}</Text>
|
||||
<ChevronRight size={14} color={colors.textMuted} />
|
||||
<ChevronRight size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
@@ -80,7 +83,7 @@ export function ContentSendersSettings() {
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Trusted senders</Text>
|
||||
<Pressable onPress={() => setModalOpen(false)} hitSlop={6}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -89,7 +92,7 @@ export function ContentSendersSettings() {
|
||||
value={newSender}
|
||||
onChangeText={setNewSender}
|
||||
placeholder="sender@example.com"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
@@ -111,7 +114,7 @@ export function ContentSendersSettings() {
|
||||
}
|
||||
}}
|
||||
disabled={!newSender.trim()}
|
||||
icon={<Plus size={14} color={colors.primaryForeground} />}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
@@ -133,7 +136,7 @@ export function ContentSendersSettings() {
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => [styles.removeBtn, pressed && styles.removeBtnPressed]}
|
||||
>
|
||||
<Trash2 size={16} color={colors.error} />
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
@@ -146,18 +149,19 @@ export function ContentSendersSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
trustedButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
trustedButtonPressed: { opacity: 0.7 },
|
||||
trustedButtonText: { ...typography.body, color: colors.text },
|
||||
trustedButtonText: { ...typography.body, color: c.text },
|
||||
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
@@ -166,10 +170,10 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: colors.card,
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
padding: spacing.lg,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
@@ -179,34 +183,35 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
modalTitle: { ...typography.h3, color: colors.text },
|
||||
modalTitle: { ...typography.h3, color: c.text },
|
||||
addRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
list: { maxHeight: 300 },
|
||||
separator: { height: 1, backgroundColor: colors.border },
|
||||
separator: { height: 1, backgroundColor: c.border },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
rowText: { ...typography.body, color: colors.text, flex: 1, marginRight: spacing.sm },
|
||||
rowText: { ...typography.body, color: c.text, flex: 1, marginRight: spacing.sm },
|
||||
removeBtn: { padding: 6, borderRadius: radius.sm },
|
||||
removeBtnPressed: { backgroundColor: colors.muted },
|
||||
removeBtnPressed: { backgroundColor: c.muted },
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@ 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';
|
||||
|
||||
type Keyword = KeywordDef;
|
||||
|
||||
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
|
||||
// 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 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);
|
||||
@@ -53,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 }]} />
|
||||
@@ -61,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>
|
||||
@@ -82,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>
|
||||
@@ -104,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()
|
||||
@@ -130,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
|
||||
@@ -142,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}
|
||||
@@ -160,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
|
||||
@@ -168,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>
|
||||
@@ -176,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',
|
||||
@@ -184,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,
|
||||
@@ -211,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',
|
||||
@@ -253,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',
|
||||
@@ -263,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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ 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 { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
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);
|
||||
@@ -30,7 +33,7 @@ export function LanguageSettings() {
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{sublabel ? <Text style={styles.sublabel}>{sublabel}</Text> : null}
|
||||
</View>
|
||||
{active ? <Check size={16} color={colors.primary} /> : null}
|
||||
{active ? <Check size={16} color={c.primary} /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
@@ -57,8 +60,9 @@ export function LanguageSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: colors.background },
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: c.background },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -66,8 +70,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
rowPressed: { backgroundColor: colors.muted },
|
||||
divider: { height: 1, backgroundColor: colors.border, marginHorizontal: spacing.md },
|
||||
label: { ...typography.body, color: colors.text },
|
||||
sublabel: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
|
||||
import { colors, spacing, typography } from '../../theme/tokens';
|
||||
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
|
||||
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' },
|
||||
@@ -12,13 +13,24 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ 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);
|
||||
|
||||
@@ -27,8 +39,21 @@ export function LayoutSettings() {
|
||||
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={colors.mutedForeground} />
|
||||
<ArrowRight size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
@@ -43,7 +68,7 @@ export function LayoutSettings() {
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<ArrowLeft size={14} color={colors.mutedForeground} />
|
||||
<ArrowLeft size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
@@ -56,16 +81,14 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SettingItem
|
||||
label=""
|
||||
description="Drag past about half a row to commit; lift earlier to cancel."
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
rowLabel: { ...typography.bodyMedium, color: colors.text },
|
||||
rowDescription: { ...typography.caption, color: colors.mutedForeground },
|
||||
rowLabel: { ...typography.bodyMedium, color: c.text },
|
||||
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ 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,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
teardownPushNotifications,
|
||||
} 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 +37,29 @@ 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 [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();
|
||||
@@ -120,26 +131,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 +163,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 +194,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 +207,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 +251,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 +270,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 +289,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
|
||||
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { AlertTriangle, FolderSync, X } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
|
||||
|
||||
type MailLayout = 'split' | 'focus';
|
||||
type DeleteAction = 'trash' | 'permanent';
|
||||
type ArchiveMode = 'single' | 'year' | 'month';
|
||||
type AttachmentAction = 'preview' | 'download';
|
||||
type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||
|
||||
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type ArchiveMode,
|
||||
type DeleteAction,
|
||||
type MailLayout,
|
||||
type MailAttachmentAction,
|
||||
type AttachmentPosition,
|
||||
} from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
||||
|
||||
function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const isSplit = value === 'split';
|
||||
return (
|
||||
<View style={styles.layoutPreview}>
|
||||
@@ -66,43 +70,97 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||
}
|
||||
|
||||
export function ReadingSettings() {
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
|
||||
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const archiveMode = useSettingsStore((s) => s.archiveMode);
|
||||
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
|
||||
const deleteAction = useSettingsStore((s) => s.deleteAction);
|
||||
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
|
||||
const showPreview = useSettingsStore((s) => s.showPreview);
|
||||
const mailLayout = useSettingsStore((s) => s.mailLayout);
|
||||
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
|
||||
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
|
||||
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
|
||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
|
||||
const [markAsReadDelay, setMarkAsReadDelay] = useState('0');
|
||||
const [deleteAction, setDeleteAction] = useState<DeleteAction>('trash');
|
||||
const [permanentlyDeleteJunk, setPermanentlyDeleteJunk] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(true);
|
||||
const [mailLayout, setMailLayout] = useState<MailLayout>('split');
|
||||
const [disableThreading, setDisableThreading] = useState(false);
|
||||
const [autoSelectReplyIdentity, setAutoSelectReplyIdentity] = useState(true);
|
||||
const [plainTextMode, setPlainTextMode] = useState(false);
|
||||
const [emailsPerPage, setEmailsPerPage] = useState('25');
|
||||
const [mailAttachmentAction, setMailAttachmentAction] = useState<AttachmentAction>('preview');
|
||||
const [attachmentPosition, setAttachmentPosition] = useState<AttachmentPosition>('beside-sender');
|
||||
const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false);
|
||||
const [archiveMode, setArchiveMode] = useState<ArchiveMode>('single');
|
||||
const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true);
|
||||
const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState<string[]>(DEFAULT_KEYWORDS);
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [hideInlineImageAttachments, setHideInlineImageAttachments] = useState(true);
|
||||
const [trustedSendersAddressBook, setTrustedSendersAddressBook] = useState(false);
|
||||
const [showTrustedList, setShowTrustedList] = useState(false);
|
||||
const [reorganizing, setReorganizing] = useState(false);
|
||||
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const handleReorganizeArchive = async () => {
|
||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||
const archiveMailbox = mailboxes.find(
|
||||
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
|
||||
);
|
||||
if (!archiveMailbox) {
|
||||
setReorganizeResult('No archive folder found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setReorganizing(true);
|
||||
setReorganizeResult(null);
|
||||
|
||||
try {
|
||||
// Drain the archive in pages so we don't OOM on huge mailboxes.
|
||||
const PAGE = 100;
|
||||
let position = 0;
|
||||
let total = 0;
|
||||
let moved = 0;
|
||||
|
||||
while (true) {
|
||||
const { ids, total: pageTotal } = await queryEmails(archiveMailbox.id, {
|
||||
position,
|
||||
limit: PAGE,
|
||||
});
|
||||
if (position === 0) total = pageTotal;
|
||||
if (ids.length === 0) break;
|
||||
|
||||
const list = await getEmails(ids);
|
||||
const refreshed = useEmailStore.getState().mailboxes;
|
||||
await archiveEmails(
|
||||
list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
|
||||
archiveMailbox.id,
|
||||
archiveMode,
|
||||
refreshed,
|
||||
);
|
||||
moved += list.length;
|
||||
|
||||
// Newly-created year/month folders need to be visible to the next batch
|
||||
// so we don't try to create the same folder twice.
|
||||
await fetchMailboxes();
|
||||
|
||||
// Items just got moved out of the root archive view; the next page
|
||||
// starts again at position 0 of the now-shorter list.
|
||||
if (ids.length < PAGE) break;
|
||||
}
|
||||
|
||||
setReorganizeResult(`Moved ${moved} of ${total} emails.`);
|
||||
} catch (err) {
|
||||
setReorganizeResult(err instanceof Error ? err.message : 'Reorganize failed.');
|
||||
} finally {
|
||||
setReorganizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
|
||||
<SettingItem label="Mark as Read" description="When to flag an email as read.">
|
||||
<Select
|
||||
value={markAsReadDelay}
|
||||
onChange={setMarkAsReadDelay}
|
||||
value={String(markAsReadDelay)}
|
||||
onChange={(v) => update('markAsReadDelay', Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: 'Instant' },
|
||||
{ value: '3000', label: 'After 3s' },
|
||||
@@ -116,7 +174,7 @@ export function ReadingSettings() {
|
||||
<SettingItem label="Delete Action" description="Where deleted emails go." noBorder />
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(v) => setDeleteAction(v as DeleteAction)}
|
||||
onChange={(v) => update('deleteAction', v as DeleteAction)}
|
||||
options={[
|
||||
{ value: 'trash', label: 'Move to Trash' },
|
||||
{ value: 'permanent', label: 'Permanently delete' },
|
||||
@@ -124,7 +182,7 @@ export function ReadingSettings() {
|
||||
/>
|
||||
{deleteAction === 'permanent' && (
|
||||
<View style={styles.warning}>
|
||||
<AlertTriangle size={14} color={colors.error} />
|
||||
<AlertTriangle size={14} color={c.error} />
|
||||
<Text style={styles.warningText}>Permanent deletion cannot be undone.</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -135,7 +193,7 @@ export function ReadingSettings() {
|
||||
<SettingItem label="Archive Mode" description="Organize archive into subfolders." noBorder />
|
||||
<Select
|
||||
value={archiveMode}
|
||||
onChange={(v) => setArchiveMode(v as ArchiveMode)}
|
||||
onChange={(v) => update('archiveMode', v as ArchiveMode)}
|
||||
options={[
|
||||
{ value: 'single', label: 'Single folder' },
|
||||
{ value: 'year', label: 'By year' },
|
||||
@@ -143,23 +201,38 @@ export function ReadingSettings() {
|
||||
]}
|
||||
/>
|
||||
{archiveMode !== 'single' && (
|
||||
<Pressable style={styles.inlineBtn}>
|
||||
<FolderSync size={14} color={colors.text} />
|
||||
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
|
||||
</Pressable>
|
||||
<>
|
||||
<Pressable
|
||||
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
|
||||
onPress={reorganizing ? undefined : handleReorganizeArchive}
|
||||
disabled={reorganizing}
|
||||
>
|
||||
{reorganizing ? (
|
||||
<ActivityIndicator size="small" color={c.text} />
|
||||
) : (
|
||||
<FolderSync size={14} color={c.text} />
|
||||
)}
|
||||
<Text style={styles.inlineBtnText}>
|
||||
{reorganizing ? 'Reorganizing…' : 'Reorganize existing archive'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{reorganizeResult && (
|
||||
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
|
||||
<SettingItem label="Permanently Delete Junk" description="Skip the trash when deleting spam.">
|
||||
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={setPermanentlyDeleteJunk} />
|
||||
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.group}>
|
||||
<SettingItem label="Mail Layout" description="Choose between split view and focus view." noBorder />
|
||||
<RadioGroup
|
||||
value={mailLayout}
|
||||
onChange={(v) => setMailLayout(v as MailLayout)}
|
||||
onChange={(v) => update('mailLayout', v as MailLayout)}
|
||||
options={[
|
||||
{ value: 'split', label: 'Split' },
|
||||
{ value: 'focus', label: 'Focus' },
|
||||
@@ -170,25 +243,25 @@ export function ReadingSettings() {
|
||||
</View>
|
||||
|
||||
<SettingItem label="Show Preview" description="Preview text under each subject.">
|
||||
<ToggleSwitch checked={showPreview} onChange={setShowPreview} />
|
||||
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Disable Thread Grouping" description="Show emails individually instead of threaded.">
|
||||
<ToggleSwitch checked={disableThreading} onChange={setDisableThreading} />
|
||||
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
|
||||
<ToggleSwitch checked={plainTextMode} onChange={setPlainTextMode} />
|
||||
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Auto-select Reply Identity" description="Pick the best identity when replying.">
|
||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Attachment Reminder" description="Warn when the word 'attached' is present but no file is attached.">
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={setAttachmentReminderEnabled}
|
||||
onChange={(v) => update('attachmentReminderEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
@@ -202,11 +275,14 @@ export function ReadingSettings() {
|
||||
<Text style={styles.chipText}>{kw}</Text>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setAttachmentReminderKeywords((list) => list.filter((k) => k !== kw))
|
||||
update(
|
||||
'attachmentReminderKeywords',
|
||||
attachmentReminderKeywords.filter((k) => k !== kw),
|
||||
)
|
||||
}
|
||||
hitSlop={6}
|
||||
>
|
||||
<X size={12} color={colors.mutedForeground} />
|
||||
<X size={12} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
@@ -216,12 +292,12 @@ export function ReadingSettings() {
|
||||
value={newKeyword}
|
||||
onChangeText={setNewKeyword}
|
||||
placeholder="Add a keyword"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.keywordInput}
|
||||
onSubmitEditing={() => {
|
||||
const t = newKeyword.trim().toLowerCase();
|
||||
if (t && !attachmentReminderKeywords.includes(t)) {
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
|
||||
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
@@ -231,7 +307,7 @@ export function ReadingSettings() {
|
||||
onPress={() => {
|
||||
const t = newKeyword.trim().toLowerCase();
|
||||
if (t && !attachmentReminderKeywords.includes(t)) {
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
|
||||
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
@@ -244,13 +320,13 @@ export function ReadingSettings() {
|
||||
)}
|
||||
|
||||
<SettingItem label="Hide Inline Image Attachments" description="Don't list inline images in the attachment list.">
|
||||
<ToggleSwitch checked={hideInlineImageAttachments} onChange={setHideInlineImageAttachments} />
|
||||
<ToggleSwitch checked={hideInlineImageAttachments} onChange={(v) => update('hideInlineImageAttachments', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Attachment Click Action" description="What happens when you tap an attachment.">
|
||||
<Select
|
||||
value={mailAttachmentAction}
|
||||
onChange={(v) => setMailAttachmentAction(v as AttachmentAction)}
|
||||
onChange={(v) => update('mailAttachmentAction', v as MailAttachmentAction)}
|
||||
options={[
|
||||
{ value: 'preview', label: 'Preview' },
|
||||
{ value: 'download', label: 'Download' },
|
||||
@@ -261,7 +337,7 @@ export function ReadingSettings() {
|
||||
<SettingItem label="Attachment Position" description="Where attachments appear in messages.">
|
||||
<Select
|
||||
value={attachmentPosition}
|
||||
onChange={(v) => setAttachmentPosition(v as AttachmentPosition)}
|
||||
onChange={(v) => update('attachmentPosition', v as AttachmentPosition)}
|
||||
options={[
|
||||
{ value: 'beside-sender', label: 'Beside sender' },
|
||||
{ value: 'below-header', label: 'Below header' },
|
||||
@@ -271,8 +347,8 @@ export function ReadingSettings() {
|
||||
|
||||
<SettingItem label="Emails Per Page" description="How many emails to load at a time.">
|
||||
<Select
|
||||
value={emailsPerPage}
|
||||
onChange={setEmailsPerPage}
|
||||
value={String(emailsPerPage)}
|
||||
onChange={(v) => update('emailsPerPage', Number(v))}
|
||||
options={[
|
||||
{ value: '10', label: '10' },
|
||||
{ value: '25', label: '25' },
|
||||
@@ -286,7 +362,8 @@ export function ReadingSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
group: {},
|
||||
warning: {
|
||||
flexDirection: 'row',
|
||||
@@ -298,12 +375,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
warningText: {
|
||||
...typography.caption,
|
||||
color: colors.error,
|
||||
color: c.error,
|
||||
flex: 1,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
backgroundColor: c.border,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
inlineBtn: {
|
||||
@@ -312,23 +389,31 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
inlineBtnText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
inlineBtnDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
reorganizeResult: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
subLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
subDesc: {
|
||||
...typography.caption,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
marginTop: 2,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
@@ -344,11 +429,11 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
chipText: {
|
||||
...typography.caption,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
addKeywordRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -360,37 +445,37 @@ const styles = StyleSheet.create({
|
||||
height: 32,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.background,
|
||||
color: colors.text,
|
||||
backgroundColor: c.background,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
addKeywordBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
addKeywordText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
layoutPreview: {
|
||||
marginTop: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
padding: spacing.md,
|
||||
},
|
||||
layoutTitle: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
layoutDesc: {
|
||||
...typography.caption,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
marginTop: 2,
|
||||
},
|
||||
layoutFrame: {
|
||||
@@ -399,27 +484,27 @@ const styles = StyleSheet.create({
|
||||
marginTop: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(24,24,27,0.4)',
|
||||
},
|
||||
layoutRail: {
|
||||
width: 32,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.border,
|
||||
borderRightColor: c.border,
|
||||
backgroundColor: 'rgba(24,24,27,0.6)',
|
||||
},
|
||||
layoutList: {
|
||||
width: 96,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
borderRightColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
layoutRow: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
layoutRowSel: {
|
||||
backgroundColor: 'rgba(59,130,246,0.1)',
|
||||
@@ -427,16 +512,16 @@ const styles = StyleSheet.create({
|
||||
layoutRowName: {
|
||||
fontSize: 9,
|
||||
fontWeight: '500',
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
layoutRowSub: {
|
||||
fontSize: 9,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
layoutReader: {
|
||||
flex: 1,
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
layoutBar: {
|
||||
height: 6,
|
||||
@@ -455,14 +540,14 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
layoutFocusText: {
|
||||
fontSize: 9,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
trustedList: {
|
||||
marginTop: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
trustedRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -472,7 +557,8 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
trustedEmail: { ...typography.caption, color: colors.text, flex: 1 },
|
||||
trustedEmail: { ...typography.caption, color: c.text, flex: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import {
|
||||
Plus, Pencil, Trash2, Globe, ExternalLink, PanelRight, GripVertical,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
|
||||
interface SidebarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
showOnMobile: boolean;
|
||||
}
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SidebarApp } from '../../stores/settings-store';
|
||||
|
||||
export function SidebarAppsSettings() {
|
||||
const [keepLoaded, setKeepLoaded] = useState(false);
|
||||
const [apps, setApps] = useState<SidebarApp[]>([]);
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const apps = useSettingsStore((s) => s.sidebarApps);
|
||||
const keepLoaded = useSettingsStore((s) => s.keepAppsLoaded);
|
||||
const addSidebarApp = useSettingsStore((s) => s.addSidebarApp);
|
||||
const updateSidebarApp = useSettingsStore((s) => s.updateSidebarApp);
|
||||
const removeSidebarApp = useSettingsStore((s) => s.removeSidebarApp);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const addApp = (data: Omit<SidebarApp, 'id'>) => {
|
||||
setApps((list) => [...list, { ...data, id: `app-${Math.random().toString(36).slice(2)}` }]);
|
||||
addSidebarApp(data);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const updateApp = (id: string, data: Omit<SidebarApp, 'id'>) => {
|
||||
setApps((list) => list.map((a) => a.id === id ? { ...data, id } : a));
|
||||
updateSidebarApp(id, data);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const removeApp = (id: string) => {
|
||||
setApps((list) => list.filter((a) => a.id !== id));
|
||||
removeSidebarApp(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="Sidebar Apps" description="Embedded web apps accessible from the sidebar.">
|
||||
<SettingItem label="Keep apps loaded" description="Maintain app state when switching away.">
|
||||
<ToggleSwitch checked={keepLoaded} onChange={setKeepLoaded} />
|
||||
<ToggleSwitch checked={keepLoaded} onChange={(v) => update('keepAppsLoaded', v)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -63,9 +69,9 @@ export function SidebarAppsSettings() {
|
||||
}
|
||||
return (
|
||||
<View key={app.id} style={styles.appRow}>
|
||||
<GripVertical size={16} color={colors.mutedForeground} style={{ opacity: 0.5 }} />
|
||||
<GripVertical size={16} color={c.mutedForeground} style={{ opacity: 0.5 }} />
|
||||
<View style={styles.appIcon}>
|
||||
<Globe size={16} color={colors.text} />
|
||||
<Globe size={16} color={c.text} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.appName} numberOfLines={1}>{app.name}</Text>
|
||||
@@ -77,16 +83,16 @@ export function SidebarAppsSettings() {
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.modeBadgeText,
|
||||
{ color: app.openMode === 'inline' ? '#60a5fa' : colors.mutedForeground },
|
||||
{ color: app.openMode === 'inline' ? '#60a5fa' : c.mutedForeground },
|
||||
]}>
|
||||
{app.openMode === 'inline' ? 'Inline' : 'Tab'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)}>
|
||||
<Pencil size={14} color={colors.mutedForeground} />
|
||||
<Pencil size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)}>
|
||||
<Trash2 size={14} color={colors.mutedForeground} />
|
||||
<Trash2 size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -101,7 +107,7 @@ export function SidebarAppsSettings() {
|
||||
|
||||
{!isAdding && editingId === null && (
|
||||
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
|
||||
<Plus size={16} color={colors.text} />
|
||||
<Plus size={16} color={c.text} />
|
||||
<Text style={styles.addBtnText}>Add app</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -118,6 +124,8 @@ interface AppFormProps {
|
||||
}
|
||||
|
||||
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||
const c = useColors();
|
||||
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [url, setUrl] = useState(initial?.url ?? '');
|
||||
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
|
||||
@@ -133,7 +141,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="App name"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={formStyles.input}
|
||||
/>
|
||||
</View>
|
||||
@@ -144,7 +152,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://example.com"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={formStyles.input}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
@@ -158,8 +166,8 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||
onPress={() => setOpenMode('tab')}
|
||||
style={[formStyles.modeBtn, openMode === 'tab' && formStyles.modeBtnActive]}
|
||||
>
|
||||
<ExternalLink size={14} color={openMode === 'tab' ? colors.primary : colors.text} />
|
||||
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: colors.primary }]}>
|
||||
<ExternalLink size={14} color={openMode === 'tab' ? c.primary : c.text} />
|
||||
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: c.primary }]}>
|
||||
New tab
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -167,8 +175,8 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||
onPress={() => setOpenMode('inline')}
|
||||
style={[formStyles.modeBtn, openMode === 'inline' && formStyles.modeBtnActive]}
|
||||
>
|
||||
<PanelRight size={14} color={openMode === 'inline' ? colors.primary : colors.text} />
|
||||
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: colors.primary }]}>
|
||||
<PanelRight size={14} color={openMode === 'inline' ? c.primary : c.text} />
|
||||
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: c.primary }]}>
|
||||
Inline
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -194,11 +202,12 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
@@ -209,25 +218,25 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
appIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
appName: { ...typography.bodyMedium, color: colors.text },
|
||||
appUrl: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
||||
appName: { ...typography.bodyMedium, color: c.text },
|
||||
appUrl: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
modeBadge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
modeBadgeInline: { backgroundColor: 'rgba(59, 130, 246, 0.1)' },
|
||||
modeBadgeTab: { backgroundColor: colors.muted },
|
||||
modeBadgeTab: { backgroundColor: c.muted },
|
||||
modeBadgeText: { fontSize: 10, fontWeight: '500' },
|
||||
iconBtn: {
|
||||
width: 28,
|
||||
@@ -244,45 +253,48 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
addBtnText: { ...typography.body, color: colors.text },
|
||||
addBtnText: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
const formStyles = StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
label: { ...typography.captionMedium, color: colors.text },
|
||||
input: {
|
||||
marginTop: 4,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
...typography.body,
|
||||
},
|
||||
modeBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
modeBtnActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
modeBtnText: { ...typography.caption, color: colors.text },
|
||||
});
|
||||
function makeFormStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
label: { ...typography.captionMedium, color: c.text },
|
||||
input: {
|
||||
marginTop: 4,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
modeBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
modeBtnActive: {
|
||||
borderColor: c.primary,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
modeBtnText: { ...typography.caption, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import {
|
||||
Upload, Trash2, Eye, Lock, Unlock, Download,
|
||||
ShieldCheck, ShieldAlert, Users,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
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 KeyRecord {
|
||||
id: string;
|
||||
@@ -31,12 +33,20 @@ const MOCK_KEYS: KeyRecord[] = [];
|
||||
const MOCK_CERTS: PublicCert[] = [];
|
||||
|
||||
export function SmimeSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const defaultEncrypt = useSettingsStore((s) => s.smimeDefaultEncrypt);
|
||||
const rememberUnlocked = useSettingsStore((s) => s.smimeRememberUnlocked);
|
||||
const autoImport = useSettingsStore((s) => s.smimeAutoImport);
|
||||
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
|
||||
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
|
||||
const [bindings, setBindings] = useState<Record<string, string>>({});
|
||||
const [defaultEncrypt, setDefaultEncrypt] = useState(false);
|
||||
const [rememberUnlocked, setRememberUnlocked] = useState(false);
|
||||
const [autoImport, setAutoImport] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const isExpired = (d: string) => new Date(d) < new Date();
|
||||
const formatDate = (d: string) => {
|
||||
@@ -64,9 +74,9 @@ export function SmimeSettings() {
|
||||
<View style={styles.certLeft}>
|
||||
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
|
||||
{expired ? (
|
||||
<ShieldAlert size={16} color={colors.error} />
|
||||
<ShieldAlert size={16} color={c.error} />
|
||||
) : (
|
||||
<ShieldCheck size={16} color={colors.primary} />
|
||||
<ShieldCheck size={16} color={c.primary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -80,19 +90,19 @@ export function SmimeSettings() {
|
||||
<View style={styles.certActions}>
|
||||
<Pressable style={styles.iconBtn} onPress={() => toggleUnlock(k.id)}>
|
||||
{k.unlocked ? (
|
||||
<Unlock size={16} color={colors.success} />
|
||||
<Unlock size={16} color={c.success} />
|
||||
) : (
|
||||
<Lock size={16} color={colors.text} />
|
||||
<Lock size={16} color={c.text} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn}>
|
||||
<Eye size={16} color={colors.text} />
|
||||
<Eye size={16} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn}>
|
||||
<Download size={16} color={colors.text} />
|
||||
<Download size={16} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn} onPress={() => deleteKey(k.id)}>
|
||||
<Trash2 size={16} color={colors.error} />
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -105,7 +115,7 @@ export function SmimeSettings() {
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
|
||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||
Import PKCS#12
|
||||
</Button>
|
||||
</View>
|
||||
@@ -116,28 +126,28 @@ export function SmimeSettings() {
|
||||
description="Public certificates used to encrypt outgoing mail."
|
||||
>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{certs.map((c) => {
|
||||
const expired = isExpired(c.notAfter);
|
||||
{certs.map((cert) => {
|
||||
const expired = isExpired(cert.notAfter);
|
||||
return (
|
||||
<View key={c.id} style={styles.certRow}>
|
||||
<View key={cert.id} style={styles.certRow}>
|
||||
<View style={styles.certLeft}>
|
||||
<View style={[styles.certIcon, styles.certIconMuted]}>
|
||||
<Users size={16} color={colors.mutedForeground} />
|
||||
<Users size={16} color={c.mutedForeground} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
|
||||
<Text style={styles.certName} numberOfLines={1}>{cert.email || cert.subject}</Text>
|
||||
<Text style={styles.certMeta}>
|
||||
{c.issuer} · {c.source}
|
||||
{cert.issuer} · {cert.source}
|
||||
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.certActions}>
|
||||
<Pressable style={styles.iconBtn}>
|
||||
<Eye size={16} color={colors.text} />
|
||||
<Eye size={16} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
|
||||
<Trash2 size={16} color={colors.error} />
|
||||
<Pressable style={styles.iconBtn} onPress={() => deleteCert(cert.id)}>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -150,7 +160,7 @@ export function SmimeSettings() {
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
|
||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||
Import Certificate
|
||||
</Button>
|
||||
</View>
|
||||
@@ -164,28 +174,29 @@ export function SmimeSettings() {
|
||||
label="Encrypt by default"
|
||||
description="Encrypt new messages when recipient certificates are available."
|
||||
>
|
||||
<ToggleSwitch checked={defaultEncrypt} onChange={setDefaultEncrypt} />
|
||||
<ToggleSwitch checked={defaultEncrypt} onChange={(v) => update('smimeDefaultEncrypt', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Remember unlocked keys"
|
||||
description="Keep private keys unlocked across the session."
|
||||
>
|
||||
<ToggleSwitch checked={rememberUnlocked} onChange={setRememberUnlocked} />
|
||||
<ToggleSwitch checked={rememberUnlocked} onChange={(v) => update('smimeRememberUnlocked', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Auto-import signer certs"
|
||||
description="Save public certificates from signed messages."
|
||||
>
|
||||
<ToggleSwitch checked={autoImport} onChange={setAutoImport} />
|
||||
<ToggleSwitch checked={autoImport} onChange={(v) => update('smimeAutoImport', v)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
certRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -195,7 +206,7 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
certLeft: {
|
||||
flexDirection: 'row',
|
||||
@@ -211,12 +222,12 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
certIconOk: { backgroundColor: colors.primaryBg },
|
||||
certIconError: { backgroundColor: colors.errorBg },
|
||||
certIconMuted: { backgroundColor: colors.muted },
|
||||
certName: { ...typography.bodyMedium, color: colors.text },
|
||||
certMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
||||
expiredText: { color: colors.error },
|
||||
certIconOk: { backgroundColor: c.primaryBg },
|
||||
certIconError: { backgroundColor: c.errorBg },
|
||||
certIconMuted: { backgroundColor: c.muted },
|
||||
certName: { ...typography.bodyMedium, color: c.text },
|
||||
certMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
expiredText: { color: c.error },
|
||||
certActions: { flexDirection: 'row', gap: 2 },
|
||||
iconBtn: {
|
||||
width: 32,
|
||||
@@ -227,8 +238,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
empty: {
|
||||
...typography.body,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +1,313 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { FileText, Download, Upload } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Share,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Download, FileText, Plus, Trash2, Upload, X } from 'lucide-react-native';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, typography } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
|
||||
|
||||
export function TemplateSettings() {
|
||||
const [templates] = useState<{ id: string; name: string }[]>([]);
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const templates = useTemplatesStore((s) => s.templates);
|
||||
const hydrated = useTemplatesStore((s) => s.hydrated);
|
||||
const hydrate = useTemplatesStore((s) => s.hydrate);
|
||||
const addTemplate = useTemplatesStore((s) => s.addTemplate);
|
||||
const updateTemplate = useTemplatesStore((s) => s.updateTemplate);
|
||||
const deleteTemplate = useTemplatesStore((s) => s.deleteTemplate);
|
||||
const exportAll = useTemplatesStore((s) => s.exportAll);
|
||||
const importTemplates = useTemplatesStore((s) => s.importTemplates);
|
||||
|
||||
const [editing, setEditing] = useState<EmailTemplate | null>(null);
|
||||
const [importVisible, setImportVisible] = useState(false);
|
||||
const [importText, setImportText] = useState('');
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [draftSubject, setDraftSubject] = useState('');
|
||||
const [draftBody, setDraftBody] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({
|
||||
id: '',
|
||||
name: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
category: '',
|
||||
isFavorite: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
});
|
||||
setDraftName('');
|
||||
setDraftSubject('');
|
||||
setDraftBody('');
|
||||
};
|
||||
|
||||
const openEdit = (t: EmailTemplate) => {
|
||||
setEditing(t);
|
||||
setDraftName(t.name);
|
||||
setDraftSubject(t.subject);
|
||||
setDraftBody(t.body);
|
||||
};
|
||||
|
||||
const closeEditor = () => setEditing(null);
|
||||
|
||||
const saveDraft = () => {
|
||||
const name = draftName.trim();
|
||||
if (!name) {
|
||||
Alert.alert('Template name required');
|
||||
return;
|
||||
}
|
||||
if (!editing) return;
|
||||
if (editing.id) {
|
||||
updateTemplate(editing.id, { name, subject: draftSubject, body: draftBody });
|
||||
} else {
|
||||
addTemplate({ name, subject: draftSubject, body: draftBody });
|
||||
}
|
||||
closeEditor();
|
||||
};
|
||||
|
||||
const confirmDelete = (t: EmailTemplate) => {
|
||||
Alert.alert(
|
||||
'Delete template',
|
||||
`Permanently delete "${t.name}"?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => deleteTemplate(t.id) },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const exportTemplatesAsShare = async () => {
|
||||
const json = exportAll();
|
||||
try {
|
||||
await Share.share({ message: json, title: 'Templates export' });
|
||||
} catch (err) {
|
||||
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unable to share');
|
||||
}
|
||||
};
|
||||
|
||||
const performImport = () => {
|
||||
const result = importTemplates(importText);
|
||||
if (result.error) {
|
||||
Alert.alert('Import failed', result.error);
|
||||
return;
|
||||
}
|
||||
setImportText('');
|
||||
setImportVisible(false);
|
||||
Alert.alert('Imported', `${result.count} template${result.count === 1 ? '' : 's'} added.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
|
||||
<View style={styles.row}>
|
||||
<SettingsSection title="Templates" description="Reusable snippets for common replies. Tap one to edit; long-press to delete.">
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.countRow}>
|
||||
<FileText size={16} color={colors.mutedForeground} />
|
||||
<FileText size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.count}>
|
||||
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
|
||||
</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm">Manage</Button>
|
||||
<Button variant="default" size="sm" onPress={openCreate} icon={<Plus size={14} color={c.primaryForeground} />}>
|
||||
New
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<Text style={styles.emptyHint}>No templates yet. Tap "New" to add one.</Text>
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
{templates.map((t) => (
|
||||
<Pressable
|
||||
key={t.id}
|
||||
onPress={() => openEdit(t)}
|
||||
onLongPress={() => confirmDelete(t)}
|
||||
style={({ pressed }) => [styles.listRow, pressed && styles.listRowPressed]}
|
||||
>
|
||||
<View style={styles.listRowText}>
|
||||
<Text style={styles.listRowName} numberOfLines={1}>{t.name}</Text>
|
||||
{!!t.subject && (
|
||||
<Text style={styles.listRowSubject} numberOfLines={1}>{t.subject}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Pressable onPress={() => confirmDelete(t)} hitSlop={8} style={styles.listRowDelete}>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Export / Import" description="Back up or restore your templates.">
|
||||
<SettingsSection title="Export / Import" description="Back up or restore your templates as JSON.">
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={templates.length === 0}
|
||||
icon={<Download size={14} color={colors.text} />}
|
||||
icon={<Download size={14} color={c.text} />}
|
||||
onPress={() => { void exportTemplatesAsShare(); }}
|
||||
>
|
||||
Export
|
||||
Share JSON
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Upload size={14} color={c.text} />}
|
||||
onPress={() => setImportVisible(true)}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Edit/Create modal */}
|
||||
<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 template' : 'New template'}
|
||||
</Text>
|
||||
<Pressable onPress={closeEditor} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||
<Text style={styles.fieldLabel}>Name</Text>
|
||||
<TextInput
|
||||
value={draftName}
|
||||
onChangeText={setDraftName}
|
||||
placeholder="Quick reply"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>Subject</Text>
|
||||
<TextInput
|
||||
value={draftSubject}
|
||||
onChangeText={setDraftSubject}
|
||||
placeholder="(optional)"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>Body</Text>
|
||||
<TextInput
|
||||
value={draftBody}
|
||||
onChangeText={setDraftBody}
|
||||
placeholder="Hi {recipient_name}, …"
|
||||
placeholderTextColor={c.textMuted}
|
||||
multiline
|
||||
style={[styles.input, styles.bodyInput]}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={closeEditor}>Cancel</Button>
|
||||
<Button variant="default" size="sm" onPress={saveDraft}>Save</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Import modal */}
|
||||
<Modal visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Import templates</Text>
|
||||
<Pressable onPress={() => setImportVisible(false)} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||
<Text style={styles.fieldLabel}>JSON</Text>
|
||||
<TextInput
|
||||
value={importText}
|
||||
onChangeText={setImportText}
|
||||
placeholder='{"templates": [...]}'
|
||||
placeholderTextColor={c.textMuted}
|
||||
multiline
|
||||
style={[styles.input, styles.bodyInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setImportVisible(false)}>Cancel</Button>
|
||||
<Button variant="default" size="sm" onPress={performImport} disabled={!importText.trim()}>
|
||||
Import
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
count: { ...typography.body, color: colors.mutedForeground },
|
||||
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
|
||||
});
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
count: { ...typography.body, color: c.mutedForeground },
|
||||
emptyHint: { ...typography.caption, color: c.mutedForeground, paddingVertical: spacing.md },
|
||||
list: { gap: spacing.sm },
|
||||
listRow: {
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
borderWidth: 1, borderColor: c.border, borderRadius: radius.md,
|
||||
padding: spacing.md, gap: spacing.md,
|
||||
},
|
||||
listRowPressed: { backgroundColor: c.surface },
|
||||
listRowText: { flex: 1 },
|
||||
listRowName: { ...typography.bodyMedium, color: c.text },
|
||||
listRowSubject: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
listRowDelete: { padding: 4 },
|
||||
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md, flexWrap: 'wrap' },
|
||||
|
||||
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,
|
||||
},
|
||||
bodyInput: { minHeight: 160, textAlignVertical: 'top' },
|
||||
modalActions: {
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
|
||||
padding: spacing.lg,
|
||||
borderTopWidth: 1, borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, Check, Palette, Trash2 } from 'lucide-react-native';
|
||||
import { Upload, Check, Palette } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem } 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 Theme {
|
||||
id: string | null;
|
||||
@@ -20,8 +22,17 @@ const BUILT_IN: Theme[] = [
|
||||
];
|
||||
|
||||
export function ThemesSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const active = useSettingsStore((s) => s.activeThemeId);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const [themes] = useState<Theme[]>(BUILT_IN);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -36,16 +47,16 @@ export function ThemesSettings() {
|
||||
return (
|
||||
<Pressable
|
||||
key={theme.id ?? 'default'}
|
||||
onPress={() => setActive(theme.id)}
|
||||
onPress={() => update('activeThemeId', theme.id)}
|
||||
style={[styles.card, isActive && styles.cardActive]}
|
||||
>
|
||||
<View style={styles.preview}>
|
||||
<Palette size={32} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
<Palette size={32} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
</View>
|
||||
<View style={{ width: '100%' }}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardName} numberOfLines={1}>{theme.name}</Text>
|
||||
{isActive && <Check size={16} color={colors.primary} />}
|
||||
{isActive && <Check size={16} color={c.primary} />}
|
||||
</View>
|
||||
<Text style={styles.cardAuthor} numberOfLines={1}>{theme.author}</Text>
|
||||
{theme.variants && (
|
||||
@@ -67,7 +78,7 @@ export function ThemesSettings() {
|
||||
label="Upload Theme"
|
||||
description="Install a custom theme 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>
|
||||
@@ -75,7 +86,8 @@ export function ThemesSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
@@ -87,19 +99,19 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.card,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.card,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cardActive: {
|
||||
borderColor: colors.primary,
|
||||
borderColor: c.primary,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.05)',
|
||||
},
|
||||
preview: {
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 10,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -108,14 +120,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cardName: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
||||
cardAuthor: { ...typography.caption, color: colors.mutedForeground },
|
||||
cardName: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
cardAuthor: { ...typography.caption, color: c.mutedForeground },
|
||||
variants: { flexDirection: 'row', gap: 4, marginTop: 4 },
|
||||
variantPill: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
variantText: { fontSize: 10, color: colors.mutedForeground },
|
||||
variantText: { fontSize: 10, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
|
||||
import { View, Text, StyleSheet, Linking } from '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 { useUpdatesStore } from '../../stores/updates-store';
|
||||
import type { InstallProgress } from '../../lib/install-update';
|
||||
|
||||
@@ -29,15 +30,16 @@ function formatProgressLabel(p: InstallProgress): string {
|
||||
function installButtonLabel(
|
||||
installing: boolean,
|
||||
progress: InstallProgress | null,
|
||||
apkAsset: unknown,
|
||||
): string {
|
||||
if (!installing) return apkAsset ? 'Install' : 'Open release';
|
||||
if (!installing) return 'Install';
|
||||
if (progress?.phase === 'installing') return 'Installing…';
|
||||
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
|
||||
return 'Downloading…';
|
||||
}
|
||||
|
||||
export function UpdatesSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useUpdatesStore((s) => s.hydrated);
|
||||
const hydrate = useUpdatesStore((s) => s.hydrate);
|
||||
const checking = useUpdatesStore((s) => s.checking);
|
||||
@@ -59,14 +61,17 @@ export function UpdatesSettings() {
|
||||
|
||||
const updateAvailable = hasUpdate();
|
||||
const apkAsset = cachedLatest?.apkAsset ?? null;
|
||||
const apkPending = updateAvailable && !apkAsset;
|
||||
|
||||
useEffect(() => {
|
||||
if (!apkPending) return;
|
||||
const id = setInterval(() => {
|
||||
void checkNow({ force: true });
|
||||
}, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [apkPending, checkNow]);
|
||||
|
||||
const onInstall = () => {
|
||||
if (!apkAsset) {
|
||||
if (cachedLatest?.htmlUrl) {
|
||||
void Linking.openURL(cachedLatest.htmlUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void installLatest();
|
||||
};
|
||||
|
||||
@@ -96,19 +101,38 @@ export function UpdatesSettings() {
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={updateAvailable ? 'Update available' : 'Check for updates'}
|
||||
description={updateAvailable
|
||||
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
||||
: undefined}
|
||||
label={
|
||||
apkPending
|
||||
? 'Update building'
|
||||
: updateAvailable
|
||||
? 'Update available'
|
||||
: 'Check for updates'
|
||||
}
|
||||
description={
|
||||
apkPending
|
||||
? `v${cachedLatest?.tag} was published, but the APK is still being built. This page will check again every 30 seconds.`
|
||||
: updateAvailable
|
||||
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{updateAvailable ? (
|
||||
{apkPending ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => void checkNow({ force: true })}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? 'Checking…' : 'Check again'}
|
||||
</Button>
|
||||
) : updateAvailable ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={onInstall}
|
||||
disabled={installing}
|
||||
>
|
||||
{installButtonLabel(installing, installProgress, apkAsset)}
|
||||
{installButtonLabel(installing, installProgress)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -122,6 +146,21 @@ export function UpdatesSettings() {
|
||||
)}
|
||||
</SettingItem>
|
||||
|
||||
{apkPending && cachedLatest?.htmlUrl ? (
|
||||
<SettingItem
|
||||
label="Release page"
|
||||
description="Watch the build progress or download the APK manually."
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
|
||||
>
|
||||
Open on GitHub
|
||||
</Button>
|
||||
</SettingItem>
|
||||
) : null}
|
||||
|
||||
{installing && installProgress ? (
|
||||
<View style={styles.progressBox}>
|
||||
<View style={styles.progressTrack}>
|
||||
@@ -158,19 +197,20 @@ export function UpdatesSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
value: { ...typography.bodyMedium, color: colors.text },
|
||||
value: { ...typography.bodyMedium, color: c.text },
|
||||
notesBox: {
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
||||
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
|
||||
notesTitle: { ...typography.caption, color: c.mutedForeground, textTransform: 'uppercase' },
|
||||
notesBody: { ...typography.body, color: c.text, lineHeight: 20 },
|
||||
progressBox: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
@@ -179,12 +219,13 @@ const styles = StyleSheet.create({
|
||||
progressTrack: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
progressLabel: { ...typography.caption, color: colors.mutedForeground },
|
||||
progressLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert
|
||||
import { AlertTriangle, Eye, EyeOff } 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 { useVacationStore } from '../../stores/vacation-store';
|
||||
|
||||
function toJmapDate(input: string): string | null {
|
||||
@@ -23,6 +24,8 @@ function fromJmapDate(value: string | null): string {
|
||||
}
|
||||
|
||||
export function VacationSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const store = useVacationStore();
|
||||
|
||||
const [enabled, setEnabled] = useState(store.isEnabled);
|
||||
@@ -75,7 +78,7 @@ export function VacationSettings() {
|
||||
if (store.isLoading && !store.hasLoaded) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +87,7 @@ export function VacationSettings() {
|
||||
return (
|
||||
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
|
||||
<View style={styles.unsupported}>
|
||||
<AlertTriangle size={16} color={colors.warning} />
|
||||
<AlertTriangle size={16} color={c.warning} />
|
||||
<Text style={styles.unsupportedText}>
|
||||
Your server does not advertise support for the JMAP vacation responder.
|
||||
</Text>
|
||||
@@ -114,7 +117,7 @@ export function VacationSettings() {
|
||||
value={fromDate}
|
||||
onChangeText={setFromDate}
|
||||
placeholder="YYYY-MM-DD HH:MM"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.dateInput}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
@@ -124,7 +127,7 @@ export function VacationSettings() {
|
||||
value={toDate}
|
||||
onChangeText={setToDate}
|
||||
placeholder="YYYY-MM-DD HH:MM"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.dateInput}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
@@ -137,7 +140,7 @@ export function VacationSettings() {
|
||||
value={subject}
|
||||
onChangeText={setSubject}
|
||||
placeholder="Out of office"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.subjectInput}
|
||||
/>
|
||||
</SettingItem>
|
||||
@@ -148,7 +151,7 @@ export function VacationSettings() {
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
placeholder="I am away from…"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.bodyInput}
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
@@ -161,9 +164,9 @@ export function VacationSettings() {
|
||||
<SettingsSection title="Preview">
|
||||
<Pressable style={styles.previewToggle} onPress={() => setShowPreview((v) => !v)}>
|
||||
{showPreview ? (
|
||||
<EyeOff size={14} color={colors.primary} />
|
||||
<EyeOff size={14} color={c.primary} />
|
||||
) : (
|
||||
<Eye size={14} color={colors.primary} />
|
||||
<Eye size={14} color={c.primary} />
|
||||
)}
|
||||
<Text style={styles.previewToggleText}>
|
||||
{showPreview ? 'Hide preview' : 'Show preview'}
|
||||
@@ -182,7 +185,7 @@ export function VacationSettings() {
|
||||
<View style={styles.warnings}>
|
||||
{warnings.map((w, i) => (
|
||||
<View key={i} style={styles.warnRow}>
|
||||
<AlertTriangle size={14} color={colors.warning} />
|
||||
<AlertTriangle size={14} color={c.warning} />
|
||||
<Text style={styles.warnText}>{w}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -204,7 +207,8 @@ export function VacationSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xl },
|
||||
centered: { paddingVertical: 40, alignItems: 'center' },
|
||||
unsupported: {
|
||||
@@ -215,7 +219,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(202,138,4,0.12)',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
unsupportedText: { ...typography.body, color: colors.warning, flex: 1 },
|
||||
unsupportedText: { ...typography.body, color: c.warning, flex: 1 },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||
pill: {
|
||||
paddingHorizontal: 8,
|
||||
@@ -223,19 +227,19 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
pillActive: { backgroundColor: 'rgba(22,163,74,0.2)' },
|
||||
pillInactive: { backgroundColor: colors.muted },
|
||||
pillInactive: { backgroundColor: c.muted },
|
||||
pillText: { fontSize: 11, fontWeight: '500' },
|
||||
pillTextActive: { color: '#4ade80' },
|
||||
pillTextInactive: { color: colors.mutedForeground },
|
||||
pillTextInactive: { color: c.mutedForeground },
|
||||
dateInput: {
|
||||
minWidth: 180,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
borderColor: c.border,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
subjectInput: {
|
||||
@@ -243,47 +247,48 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
borderColor: c.border,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
bodyBlock: { paddingVertical: spacing.md },
|
||||
bodyLabel: { ...typography.bodyMedium, color: colors.text },
|
||||
bodyDesc: { ...typography.caption, color: colors.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
|
||||
bodyLabel: { ...typography.bodyMedium, color: c.text },
|
||||
bodyDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
|
||||
bodyInput: {
|
||||
width: '100%',
|
||||
minHeight: 96,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
borderColor: c.border,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
previewToggle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
previewToggleText: { ...typography.body, color: colors.primary },
|
||||
previewToggleText: { ...typography.body, color: c.primary },
|
||||
previewBox: {
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.lg,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
previewSubject: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
|
||||
previewBody: { ...typography.body, color: colors.mutedForeground },
|
||||
previewSubject: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
|
||||
previewBody: { ...typography.body, color: c.mutedForeground },
|
||||
warnings: { gap: spacing.sm },
|
||||
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
warnText: { ...typography.body, color: colors.warning, flex: 1 },
|
||||
warnText: { ...typography.body, color: c.warning, flex: 1 },
|
||||
errorBox: {
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
backgroundColor: c.errorBg,
|
||||
},
|
||||
errorText: { ...typography.body, color: colors.error },
|
||||
errorText: { ...typography.body, color: c.error },
|
||||
saveRow: { alignItems: 'flex-end' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { FlaskConical, Lock, ChevronDown, Check } 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 ToggleSwitchComponent from '../ToggleSwitch';
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,8 @@ export function SettingsSection({
|
||||
experimental,
|
||||
experimentalDescription,
|
||||
}: SettingsSectionProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
{experimental && (
|
||||
@@ -73,12 +76,14 @@ export function SettingItem({
|
||||
locked,
|
||||
noBorder,
|
||||
}: SettingItemProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked]}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingLabelRow}>
|
||||
<Text style={styles.settingLabel}>{label}</Text>
|
||||
{locked && <Lock size={12} color={colors.mutedForeground} />}
|
||||
{locked && <Lock size={12} color={c.mutedForeground} />}
|
||||
</View>
|
||||
{description && <Text style={styles.settingDescription}>{description}</Text>}
|
||||
</View>
|
||||
@@ -111,6 +116,8 @@ interface RadioGroupProps {
|
||||
}
|
||||
|
||||
export function RadioGroup({ value, onChange, options, style }: RadioGroupProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.radioGroup, style]}>
|
||||
{options.map((opt) => {
|
||||
@@ -145,6 +152,8 @@ interface SelectProps {
|
||||
}
|
||||
|
||||
export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = options.find((o) => o.value === value);
|
||||
|
||||
@@ -152,7 +161,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
<>
|
||||
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
|
||||
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
|
||||
<ChevronDown size={14} color={colors.mutedForeground} />
|
||||
<ChevronDown size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Modal
|
||||
visible={open}
|
||||
@@ -178,7 +187,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
}}
|
||||
>
|
||||
<Text style={styles.selectItemText}>{opt.label}</Text>
|
||||
{selected && <Check size={16} color={colors.primary} />}
|
||||
{selected && <Check size={16} color={c.primary} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
@@ -190,7 +199,8 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
section: {
|
||||
gap: spacing.lg,
|
||||
},
|
||||
@@ -199,11 +209,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.h3,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
sectionDescription: {
|
||||
...typography.body,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
marginTop: 4,
|
||||
},
|
||||
experimentalBanner: {
|
||||
@@ -233,7 +243,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
settingItemBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
locked: {
|
||||
opacity: 0.6,
|
||||
@@ -249,11 +259,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
settingLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
settingDescription: {
|
||||
...typography.caption,
|
||||
color: colors.mutedForeground,
|
||||
color: c.mutedForeground,
|
||||
marginTop: 2,
|
||||
},
|
||||
settingRight: {
|
||||
@@ -270,20 +280,20 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
radioSelected: {
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
radioUnselected: {
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
radioText: {
|
||||
...typography.caption,
|
||||
},
|
||||
radioTextSelected: {
|
||||
color: colors.primaryForeground,
|
||||
color: c.primaryForeground,
|
||||
fontWeight: '500',
|
||||
},
|
||||
radioTextUnselected: {
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
select: {
|
||||
flexDirection: 'row',
|
||||
@@ -292,14 +302,14 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.muted,
|
||||
backgroundColor: c.muted,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
minWidth: 120,
|
||||
},
|
||||
selectText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
flex: 1,
|
||||
},
|
||||
modalBackdrop: {
|
||||
@@ -313,10 +323,10 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
maxWidth: 320,
|
||||
maxHeight: '70%',
|
||||
backgroundColor: colors.popover,
|
||||
backgroundColor: c.popover,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
selectItem: {
|
||||
@@ -327,10 +337,11 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
selectItemPressed: {
|
||||
backgroundColor: colors.accent,
|
||||
backgroundColor: c.accent,
|
||||
},
|
||||
selectItemText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../compose-html';
|
||||
|
||||
describe('buildInitialHtml', () => {
|
||||
it('returns empty paragraph for plain compose', () => {
|
||||
expect(buildInitialHtml('compose')).toBe('<p><br></p>');
|
||||
});
|
||||
|
||||
it('returns empty paragraph when reply has no body', () => {
|
||||
expect(buildInitialHtml('reply', { from: { email: 'a@b.com' } })).toBe('<p><br></p>');
|
||||
});
|
||||
|
||||
it('builds reply with quoted plain-text body and sender label', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { name: 'Alice', email: 'a@b.com' },
|
||||
subject: 'Hello',
|
||||
body: 'line one\nline two',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('<p><br></p>');
|
||||
expect(html).toContain('Alice <a@b.com> wrote:');
|
||||
expect(html).toContain('<blockquote');
|
||||
expect(html).toContain('line one<br>line two');
|
||||
});
|
||||
|
||||
it('uses HTML body when present, stripping dangerous tags', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'x@y.com' },
|
||||
body: 'fallback',
|
||||
htmlBody: '<p>safe</p><script>alert(1)</script>',
|
||||
});
|
||||
expect(html).toContain('<p>safe</p>');
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('forward: includes "Forwarded message" header and From/Subject lines', () => {
|
||||
const html = buildInitialHtml('forward', {
|
||||
from: { name: 'Bob', email: 'b@c.com' },
|
||||
subject: 'Original Subject',
|
||||
body: 'forwarded body',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('Forwarded message');
|
||||
expect(html).toContain('From: Bob <b@c.com>');
|
||||
expect(html).toContain('Subject: Original Subject');
|
||||
expect(html).toContain('forwarded body');
|
||||
});
|
||||
|
||||
it('escapes HTML in plain-text quoted body', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'a@b.com' },
|
||||
body: '<script>x</script>',
|
||||
});
|
||||
expect(html).not.toContain('<script>x</script>');
|
||||
expect(html).toContain('<script>x</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlainText', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToPlainText('')).toBe('');
|
||||
});
|
||||
|
||||
it('strips tags and decodes entities', () => {
|
||||
expect(htmlToPlainText('<p>hello & world</p>')).toBe('hello & world');
|
||||
});
|
||||
|
||||
it('converts <br> to newline', () => {
|
||||
expect(htmlToPlainText('a<br>b<br/>c')).toBe('a\nb\nc');
|
||||
});
|
||||
|
||||
it('treats block boundaries as newlines', () => {
|
||||
expect(htmlToPlainText('<p>a</p><p>b</p>')).toBe('a\n\nb');
|
||||
});
|
||||
|
||||
it('renders list items with bullet prefix', () => {
|
||||
expect(htmlToPlainText('<ul><li>one</li><li>two</li></ul>')).toBe('• one\n• two');
|
||||
});
|
||||
|
||||
it('collapses excessive blank lines', () => {
|
||||
expect(htmlToPlainText('<p>a</p><br><br><br><p>b</p>')).toBe('a\n\nb');
|
||||
});
|
||||
|
||||
it('decodes to space', () => {
|
||||
expect(htmlToPlainText('hello world')).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteInlineImages', () => {
|
||||
it('returns input unchanged when no data-cid is present', () => {
|
||||
const html = '<p>hi <img src="https://x"></p>';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toBe(html);
|
||||
expect(out.usedCids).toEqual([]);
|
||||
});
|
||||
|
||||
it('rewrites data-cid images to cid: src and reports the CID', () => {
|
||||
const html = '<p><img src="data:image/png;base64,xxx" data-cid="abc@x" alt="pic"></p>';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('src="cid:abc@x"');
|
||||
expect(out.html).not.toContain('data-cid');
|
||||
expect(out.html).not.toContain('data:image/png');
|
||||
expect(out.usedCids).toEqual(['abc@x']);
|
||||
});
|
||||
|
||||
it('handles single-quoted attributes', () => {
|
||||
const html = `<img src='data:image/png' data-cid='zzz' alt='x'>`;
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('src="cid:zzz"');
|
||||
expect(out.usedCids).toEqual(['zzz']);
|
||||
});
|
||||
|
||||
it('preserves alt attribute on rewritten images', () => {
|
||||
const html = '<img src="data:" alt="my pic" data-cid="m1">';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('alt="my pic"');
|
||||
});
|
||||
|
||||
it('reports each cid only once even if image referenced multiple times', () => {
|
||||
const html = '<img src="data:" data-cid="dup"><img src="data:" data-cid="dup">';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.usedCids).toEqual(['dup']);
|
||||
});
|
||||
});
|
||||
@@ -117,9 +117,29 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
|
||||
safety++;
|
||||
}
|
||||
}
|
||||
// Sort each day's bucket: all-day events first, then timed events by start.
|
||||
// Without this, events from different calendars come out in arrival order
|
||||
// (roughly grouped by calendar) rather than chronologically.
|
||||
for (const arr of idx.values()) {
|
||||
arr.sort(compareEventsForDay);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function compareEventsForDay(a: CalendarEvent, b: CalendarEvent): number {
|
||||
const aAllDay = !!a.showWithoutTime;
|
||||
const bAllDay = !!b.showWithoutTime;
|
||||
if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
|
||||
const aStart = getEventStartDate(a).getTime();
|
||||
const bStart = getEventStartDate(b).getTime();
|
||||
if (aStart !== bStart) return aStart - bStart;
|
||||
// Tie-breaker on end (shorter events first), then title for stability.
|
||||
const aEnd = getEventEndDate(a).getTime();
|
||||
const bEnd = getEventEndDate(b).getTime();
|
||||
if (aEnd !== bEnd) return aEnd - bEnd;
|
||||
return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
}
|
||||
|
||||
export function eventsOnDayFromIndex(
|
||||
index: EventDayIndex,
|
||||
day: Date,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Build the initial HTML body for a new compose / reply / forward, mirroring
|
||||
// what the webmail composer (`components/email/email-composer.tsx`) produces.
|
||||
// All values are HTML-escaped before insertion into the document.
|
||||
|
||||
import { escapeHtml, stripDangerousTags } from './email-html';
|
||||
|
||||
export interface ReplyMeta {
|
||||
from?: { email?: string; name?: string };
|
||||
to?: Array<{ email?: string; name?: string }>;
|
||||
cc?: Array<{ email?: string; name?: string }>;
|
||||
subject?: string;
|
||||
body?: string; // plain-text body (preferred when htmlBody absent)
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
function escapeForHtmlBody(text: string): string {
|
||||
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function senderLabel(from?: { email?: string; name?: string }): string {
|
||||
if (!from) return '';
|
||||
if (from.name && from.email) return `${from.name} <${from.email}>`;
|
||||
return from.name || from.email || '';
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the seed HTML for a new compose / reply / forward. Returns an empty
|
||||
* paragraph for plain compose (so the contenteditable has a caret target).
|
||||
*/
|
||||
export function buildInitialHtml(
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
|
||||
reply?: ReplyMeta | null,
|
||||
): string {
|
||||
if (!reply || mode === 'compose') return '<p><br></p>';
|
||||
|
||||
const date = formatDate(reply.receivedAt);
|
||||
const fromStr = senderLabel(reply.from) || 'Unknown';
|
||||
const subject = reply.subject ?? '';
|
||||
|
||||
const quotedBody = reply.htmlBody
|
||||
? stripDangerousTags(reply.htmlBody)
|
||||
: reply.body
|
||||
? escapeForHtmlBody(reply.body)
|
||||
: '';
|
||||
|
||||
if (!quotedBody) return '<p><br></p>';
|
||||
|
||||
if (mode === 'forward') {
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>---------- Forwarded message ----------<br>',
|
||||
`From: ${escapeHtml(fromStr)}<br>`,
|
||||
date ? `Date: ${escapeHtml(date)}<br>` : '',
|
||||
subject ? `Subject: ${escapeHtml(subject)}<br>` : '',
|
||||
reply.to && reply.to.length
|
||||
? `To: ${escapeHtml(reply.to.map(senderLabel).filter(Boolean).join(', '))}<br>`
|
||||
: '',
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
// reply / replyAll
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>',
|
||||
`On ${escapeHtml(date || 'a previous date')}, ${escapeHtml(fromStr)} wrote:`,
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags and decode common entities for a plain-text fallback body.
|
||||
* Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't
|
||||
* have DOMParser in the RN runtime.
|
||||
*/
|
||||
export function htmlToPlainText(html: string): string {
|
||||
if (!html) return '';
|
||||
let s = html;
|
||||
// Treat block-level boundaries and <br> as newlines before stripping tags.
|
||||
s = s.replace(/<br\s*\/?>/gi, '\n');
|
||||
// Paragraph-level closes get a blank line; list/heading/cell closes a single
|
||||
// newline so they stack tightly.
|
||||
s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n');
|
||||
s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n');
|
||||
s = s.replace(/<li[^>]*>/gi, '• ');
|
||||
s = s.replace(/<[^>]+>/g, '');
|
||||
// Decode the most common entities; leave the rest as-is.
|
||||
s = s
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'");
|
||||
// Collapse runs of >2 newlines and trim trailing whitespace per line.
|
||||
s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n');
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the editor HTML, replacing `<img data-cid="X" ...>` with `<img src="cid:X">`
|
||||
* (and dropping the data-cid attribute). Returns the rewritten HTML and the
|
||||
* set of CIDs actually referenced so the sender can include exactly the
|
||||
* needed inline parts.
|
||||
*/
|
||||
export function rewriteInlineImages(html: string): { html: string; usedCids: string[] } {
|
||||
if (!html || !/data-cid=/i.test(html)) return { html, usedCids: [] };
|
||||
const used = new Set<string>();
|
||||
const out = html.replace(
|
||||
/<img\b([^>]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi,
|
||||
(_full, before: string, _quoted: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
||||
const cid = dq ?? sq ?? '';
|
||||
if (!cid) return _full;
|
||||
used.add(cid);
|
||||
// Drop any existing src= and replace with cid: form.
|
||||
const merged = (before + after)
|
||||
.replace(/\ssrc=("[^"]*"|'[^']*')/gi, '')
|
||||
.trim();
|
||||
const space = merged ? ' ' + merged : '';
|
||||
return `<img src="cid:${cid}"${space}>`;
|
||||
},
|
||||
);
|
||||
return { html: out, usedCids: Array.from(used) };
|
||||
}
|
||||
@@ -119,6 +119,60 @@ export function getBirthday(contact: ContactCard): AnniversaryDate | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||
if ((dateInput as Timestamp)['@type'] === 'Timestamp') {
|
||||
const ts = dateInput as Timestamp;
|
||||
const d = new Date(ts.utc);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
return { year: pd.year, month: pd.month, day: pd.day };
|
||||
}
|
||||
const s = String(dateInput);
|
||||
if (s.startsWith('--')) {
|
||||
const parts = s.substring(2).split('-');
|
||||
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||
}
|
||||
const d = new Date(s);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
const { year, month, day } = getDateParts(dateInput);
|
||||
if (!year) return null;
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - year;
|
||||
const m = month ?? 1;
|
||||
const d = day ?? 1;
|
||||
const nowM = now.getMonth() + 1;
|
||||
const nowD = now.getDate();
|
||||
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
|
||||
if (years < 0) return null;
|
||||
return years;
|
||||
}
|
||||
|
||||
export function getPhoneFeatures(features?: Record<string, boolean>): string[] {
|
||||
if (!features) return [];
|
||||
return Object.keys(features).filter((k) => features[k]);
|
||||
}
|
||||
|
||||
export function getActiveContexts(contexts?: Record<string, boolean>): string[] {
|
||||
if (!contexts) return [];
|
||||
return Object.keys(contexts).filter((k) => contexts[k]);
|
||||
}
|
||||
|
||||
export function getPrimaryNickname(contact: ContactCard): string | undefined {
|
||||
if (!contact.nicknames) return undefined;
|
||||
return Object.values(contact.nicknames)[0]?.name;
|
||||
}
|
||||
|
||||
export function formatAddress(address: {
|
||||
components?: Array<{ kind: string; value: string }>;
|
||||
full?: string;
|
||||
|
||||
+69
-5
@@ -1,3 +1,4 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
@@ -6,9 +7,72 @@ import { getDownloadUrl } from '../api/blob';
|
||||
const RFC822 = 'message/rfc822';
|
||||
|
||||
function authHeader(): string {
|
||||
const h = (jmapClient as { authHeader?: string }).authHeader;
|
||||
if (!h) throw new Error('Not connected');
|
||||
return h;
|
||||
return jmapClient.authHeader;
|
||||
}
|
||||
|
||||
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
||||
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||
if (cleaned) return cleaned.slice(0, 120);
|
||||
return `attachment.${fallbackExt}`;
|
||||
}
|
||||
|
||||
// On Android, sharing a `file://` URI from the cache directory throws a
|
||||
// FileUriExposedException on modern OS versions; the system requires a
|
||||
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
|
||||
// via `file.contentUri`, which is the only field iOS doesn't expose.
|
||||
function shareableUri(file: { uri: string; contentUri?: string }): string {
|
||||
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
|
||||
return file.uri;
|
||||
}
|
||||
|
||||
export async function shareAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(shareableUri(downloaded), {
|
||||
mimeType,
|
||||
dialogTitle: filename,
|
||||
});
|
||||
}
|
||||
|
||||
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
|
||||
// so on both platforms we land the file in the document directory and hand it
|
||||
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
|
||||
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
|
||||
// uses cache + share, which on most viewers opens directly without prompting.
|
||||
export async function downloadAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.document, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(shareableUri(downloaded), {
|
||||
mimeType,
|
||||
dialogTitle: filename,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRawEmail(blobId: string): Promise<string> {
|
||||
@@ -25,15 +89,15 @@ function safeFilename(subject: string | undefined): string {
|
||||
|
||||
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
|
||||
const dest = new File(Paths.cache, safeFilename(subject));
|
||||
if (dest.exists) dest.delete();
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
await Sharing.shareAsync(shareableUri(downloaded), {
|
||||
mimeType: RFC822,
|
||||
dialogTitle: 'Share email',
|
||||
UTI: 'public.email-message',
|
||||
|
||||
@@ -11,6 +11,25 @@ import {
|
||||
|
||||
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
|
||||
// import the Zustand store (would pull in React) so we read AsyncStorage
|
||||
// directly. Keep this in sync if the store key ever changes.
|
||||
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
|
||||
|
||||
interface PushPersistedSettings {
|
||||
emailNotificationsEnabled?: boolean;
|
||||
}
|
||||
|
||||
async function emailNotificationsAllowed(): Promise<boolean> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (!raw) return true; // first launch: default to on
|
||||
const parsed = JSON.parse(raw) as PushPersistedSettings;
|
||||
return parsed.emailNotificationsEnabled !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface ShowNotificationOptions {
|
||||
notificationId: string;
|
||||
@@ -33,6 +52,8 @@ interface BulwarkFcmNative {
|
||||
// resolve so the native service can release its wake lock.
|
||||
export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
try {
|
||||
if (!(await emailNotificationsAllowed())) return;
|
||||
|
||||
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
|
||||
if (!accountId) return;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
listPushSubscriptions,
|
||||
updatePushSubscription,
|
||||
verifyPushSubscription,
|
||||
} from '../api/push';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
@@ -26,6 +27,20 @@ export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.o
|
||||
// outgoing mail state changes don't belong in a user-visible push.
|
||||
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
|
||||
|
||||
// Maximum expires we ask the server for. Stalwart (and other JMAP servers)
|
||||
// may clamp this down; whatever they return is what we get. Without this,
|
||||
// the server picks its own (often short) default and the subscription
|
||||
// silently expires between app updates - so push stops arriving until the
|
||||
// user re-enables it from settings.
|
||||
const SUBSCRIPTION_EXPIRES_DAYS = 90;
|
||||
// When an existing subscription has less than this much lifetime left, push
|
||||
// expires forward on the next app start.
|
||||
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
|
||||
|
||||
function expiresFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
type BulwarkFcmNative = {
|
||||
getToken(): Promise<string>;
|
||||
deleteToken(): Promise<void>;
|
||||
@@ -129,7 +144,11 @@ async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<string> {
|
||||
const timeoutAt = Date.now() + 20_000;
|
||||
// Stalwart per-account rate-limits PushVerification posts (default 60s).
|
||||
// If there are leftover unverified subscriptions on the account, our new
|
||||
// one queues up behind them - so we wait long enough to clear at least one
|
||||
// verify window even in the unlucky case.
|
||||
const timeoutAt = Date.now() + 75_000;
|
||||
let delay = 400;
|
||||
while (Date.now() < timeoutAt) {
|
||||
const res = await fetch(
|
||||
@@ -186,20 +205,41 @@ export async function setupPushNotifications(
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
|
||||
// Reuse the previous JMAP subscription when the server still has it.
|
||||
// Reuse the previous JMAP subscription when the server still has it, but
|
||||
// push the expiry forward so it doesn't time out before the next app start.
|
||||
const existingSubs = await listPushSubscriptions().catch(() => []);
|
||||
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
if (storedServerId) {
|
||||
const existing = await listPushSubscriptions().catch(() => []);
|
||||
if (existing.some((s) => s.id === storedServerId)) {
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(match);
|
||||
if (refreshed) {
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
// Server rejected the refresh (likely the subscription was already
|
||||
// deleted server-side) - drop the stale id and recreate below.
|
||||
await destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
}
|
||||
|
||||
// Reap any leftover Stalwart subscriptions still bound to this device.
|
||||
// These pile up when a previous enable attempt failed mid-flow (verify
|
||||
// race, network blip, app killed). Stalwart per-account rate-limits
|
||||
// PushVerification posts, so leaving stragglers around blocks the new
|
||||
// one's verification - the symptom is a confusing "code does not match".
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
for (const s of stragglers) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
}
|
||||
|
||||
const serverAssignedId = await createPushSubscription({
|
||||
deviceClientId,
|
||||
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
||||
types: [...PUSH_TYPES],
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
});
|
||||
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
@@ -210,6 +250,29 @@ export async function setupPushNotifications(
|
||||
return { subscriptionId: serverAssignedId, verified: true };
|
||||
}
|
||||
|
||||
// Push the subscription's expires forward when it's getting close to the
|
||||
// server's ceiling. Returns false if the server rejects the update, which the
|
||||
// caller treats as "recreate".
|
||||
async function refreshSubscriptionExpires(
|
||||
sub: { id: string; expires?: string | null },
|
||||
): Promise<boolean> {
|
||||
if (sub.expires) {
|
||||
const remainingMs = new Date(sub.expires).getTime() - Date.now();
|
||||
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
|
||||
// Plenty of life left - skip the update round-trip.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await updatePushSubscription(sub.id, {
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Animated, PanResponder } from 'react-native';
|
||||
|
||||
interface Options {
|
||||
/** Animated value driving the sheet's translateY. Resting (open) position is 0. */
|
||||
slideY: Animated.Value;
|
||||
/** Off-screen target the sheet animates to when dismissed. */
|
||||
closedY: number;
|
||||
/** Called when the gesture commits a dismissal. */
|
||||
onClose: () => void;
|
||||
/** Pixels of downward drag needed to commit a dismiss on release. */
|
||||
closeDistance?: number;
|
||||
/** Downward velocity (px/ms) that commits a dismiss regardless of distance. */
|
||||
closeVelocity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires drag-to-dismiss on a bottom sheet's grab handle. Spread the returned
|
||||
* panHandlers on the handle/header area — not the scrollable body — so vertical
|
||||
* scrolling inside the sheet is not intercepted.
|
||||
*/
|
||||
export function useSheetDrag({
|
||||
slideY,
|
||||
closedY,
|
||||
onClose,
|
||||
closeDistance = 100,
|
||||
closeVelocity = 0.6,
|
||||
}: Options) {
|
||||
const startRef = React.useRef(0);
|
||||
|
||||
const panResponder = React.useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: (_, g) =>
|
||||
Math.abs(g.dy) > 4 && Math.abs(g.dy) > Math.abs(g.dx),
|
||||
onPanResponderGrant: () => {
|
||||
slideY.stopAnimation((v) => {
|
||||
startRef.current = v;
|
||||
});
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const next = startRef.current + Math.max(0, g.dy);
|
||||
slideY.setValue(next);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
const traveled = startRef.current + Math.max(0, g.dy);
|
||||
if (traveled > closeDistance || g.vy > closeVelocity) {
|
||||
Animated.timing(slideY, {
|
||||
toValue: closedY,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) onClose();
|
||||
});
|
||||
} else {
|
||||
Animated.spring(slideY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
bounciness: 0,
|
||||
}).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
Animated.spring(slideY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
bounciness: 0,
|
||||
}).start();
|
||||
},
|
||||
}),
|
||||
[slideY, closedY, onClose, closeDistance, closeVelocity],
|
||||
);
|
||||
|
||||
return panResponder.panHandlers;
|
||||
}
|
||||
@@ -31,9 +31,11 @@ import {
|
||||
addWeeks,
|
||||
subWeeks,
|
||||
} from 'date-fns';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button } from '../components';
|
||||
import { useCalendarStore } from '../stores/calendar-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { MonthView } from '../components/calendar/MonthView';
|
||||
import { WeekView } from '../components/calendar/WeekView';
|
||||
import { AgendaView } from '../components/calendar/AgendaView';
|
||||
@@ -61,15 +63,19 @@ type PendingAction =
|
||||
const AGENDA_DAYS = 30;
|
||||
const RANGE_BUFFER_DAYS = 14;
|
||||
|
||||
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
|
||||
function rangeForView(
|
||||
viewMode: ViewMode,
|
||||
currentDate: Date,
|
||||
weekStartsOn: 0 | 1,
|
||||
): { after: Date; before: Date } {
|
||||
if (viewMode === 'month') {
|
||||
const start = startOfWeek(startOfMonth(currentDate));
|
||||
const end = endOfWeek(endOfMonth(currentDate));
|
||||
const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
|
||||
const end = endOfWeek(endOfMonth(currentDate), { weekStartsOn });
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
const start = startOfWeek(currentDate, { weekStartsOn });
|
||||
const end = endOfWeek(currentDate, { weekStartsOn });
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
return {
|
||||
@@ -78,11 +84,15 @@ function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; bef
|
||||
};
|
||||
}
|
||||
|
||||
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
|
||||
function headerTitle(
|
||||
viewMode: ViewMode,
|
||||
currentDate: Date,
|
||||
weekStartsOn: 0 | 1,
|
||||
): string {
|
||||
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
const start = startOfWeek(currentDate, { weekStartsOn });
|
||||
const end = endOfWeek(currentDate, { weekStartsOn });
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
|
||||
}
|
||||
@@ -92,9 +102,22 @@ function headerTitle(viewMode: ViewMode, currentDate: Date): string {
|
||||
}
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
|
||||
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||
|
||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = React.useState(new Date());
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
|
||||
// Day view falls back to Agenda on mobile (we don't have a dedicated day
|
||||
// grid yet, but the agenda's 30-day view already serves the same purpose).
|
||||
const initialViewMode: ViewMode =
|
||||
calendarDefaultView === 'week' ? 'week'
|
||||
: calendarDefaultView === 'day' || calendarDefaultView === 'agenda' ? 'agenda'
|
||||
: 'month';
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>(initialViewMode);
|
||||
|
||||
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||
@@ -141,9 +164,9 @@ export default function CalendarScreen() {
|
||||
}, [hydrate, fetchCalendarsAction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { after, before } = rangeForView(viewMode, currentDate);
|
||||
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
|
||||
void ensureRange(after.toISOString(), before.toISOString());
|
||||
}, [viewMode, currentDate, ensureRange]);
|
||||
}, [viewMode, currentDate, ensureRange, calendarFirstDayOfWeek]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
setCurrentDate((d) => {
|
||||
@@ -261,10 +284,10 @@ export default function CalendarScreen() {
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
>
|
||||
<Menu size={20} color={colors.text} />
|
||||
<Menu size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
|
||||
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate, calendarFirstDayOfWeek)}</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||
</Text>
|
||||
@@ -281,26 +304,26 @@ export default function CalendarScreen() {
|
||||
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
|
||||
onPress={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
|
||||
<Icon size={16} color={active ? c.primary : c.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
<Plus size={18} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.nav}>
|
||||
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronLeft size={20} color={colors.text} />
|
||||
<ChevronLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Button variant="outline" size="sm" onPress={goToday}>
|
||||
Today
|
||||
</Button>
|
||||
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronRight size={20} color={colors.text} />
|
||||
<ChevronRight size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -318,6 +341,8 @@ export default function CalendarScreen() {
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
showWeekNumbers={calendarShowWeekNumbers}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={openCreate}
|
||||
/>
|
||||
@@ -328,6 +353,8 @@ export default function CalendarScreen() {
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={setDetailEvent}
|
||||
onCreateAtTime={openCreate}
|
||||
@@ -350,7 +377,7 @@ export default function CalendarScreen() {
|
||||
<Text style={styles.dayDetailTitle}>
|
||||
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
|
||||
</Text>
|
||||
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
|
||||
{loading && <ActivityIndicator size="small" color={c.textMuted} />}
|
||||
</View>
|
||||
<DayEventList
|
||||
date={selectedDate}
|
||||
@@ -415,6 +442,8 @@ function DayEventList({
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const dayEvents = React.useMemo(
|
||||
() => eventsOnDayFromIndex(eventsByDay, date),
|
||||
[eventsByDay, date],
|
||||
@@ -424,10 +453,10 @@ function DayEventList({
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emptyState}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.textMuted} />
|
||||
}
|
||||
>
|
||||
<CalendarDays size={32} color={colors.surfaceActive} />
|
||||
<CalendarDays size={32} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No events</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
|
||||
</ScrollView>
|
||||
@@ -437,7 +466,7 @@ function DayEventList({
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dayList}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.textMuted} />
|
||||
}
|
||||
>
|
||||
{dayEvents.map((event) => (
|
||||
@@ -452,8 +481,9 @@ function DayEventList({
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -470,12 +500,12 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerLeft: { flex: 1 },
|
||||
headerTitle: { ...typography.h3, color: colors.text },
|
||||
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
headerSubtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
viewToggle: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderRadius: radius.md,
|
||||
padding: 2,
|
||||
},
|
||||
@@ -486,12 +516,12 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
viewToggleBtnActive: { backgroundColor: colors.background },
|
||||
viewToggleBtnActive: { backgroundColor: c.background },
|
||||
fab: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -512,18 +542,18 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
errorBanner: {
|
||||
backgroundColor: colors.errorBg,
|
||||
backgroundColor: c.errorBg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
errorText: { ...typography.caption, color: c.errorForeground },
|
||||
|
||||
content: { flex: 1 },
|
||||
|
||||
dayDetail: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
dayDetailHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -532,7 +562,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
|
||||
dayDetailTitle: { ...typography.bodyMedium, color: c.text },
|
||||
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
|
||||
|
||||
emptyState: {
|
||||
@@ -541,6 +571,7 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.xs,
|
||||
flexGrow: 1,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
|
||||
+824
-127
File diff suppressed because it is too large
Load Diff
+697
-321
File diff suppressed because it is too large
Load Diff
+1050
-405
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,8 @@ import { ContactListRow } from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
@@ -77,6 +78,8 @@ function categoryTitle(category: ContactCategory, books: Array<{ id: string; nam
|
||||
}
|
||||
|
||||
export default function ContactsScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
@@ -227,7 +230,7 @@ export default function ContactsScreen() {
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<X size={22} color={colors.text} />
|
||||
<X size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -236,14 +239,14 @@ export default function ContactsScreen() {
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
<Trash2 size={20} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<Menu size={22} color={colors.text} />
|
||||
<Menu size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
||||
<Text style={styles.headerCount}>{visible.length}</Text>
|
||||
@@ -253,14 +256,14 @@ export default function ContactsScreen() {
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Search size={20} color={colors.text} />
|
||||
<Search size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
<Plus size={18} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
@@ -269,18 +272,18 @@ export default function ContactsScreen() {
|
||||
|
||||
{searchActive && (
|
||||
<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={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -346,17 +349,17 @@ export default function ContactsScreen() {
|
||||
void fetchContacts();
|
||||
void fetchAddressBooks();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
tintColor={c.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<UserCircle size={48} color={colors.surfaceActive} />
|
||||
<UserCircle size={48} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||
</Text>
|
||||
@@ -381,8 +384,9 @@ export default function ContactsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
listContent: { paddingBottom: 80 },
|
||||
|
||||
header: {
|
||||
@@ -392,11 +396,11 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
|
||||
headerCount: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.surface,
|
||||
color: c.textMuted,
|
||||
backgroundColor: c.surface,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
@@ -421,7 +425,7 @@ const styles = StyleSheet.create({
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
@@ -432,13 +436,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
|
||||
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
|
||||
|
||||
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
|
||||
chipBarContent: {
|
||||
@@ -450,33 +454,33 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
borderColor: c.primary,
|
||||
},
|
||||
chipText: { ...typography.captionMedium, color: colors.textSecondary },
|
||||
chipTextActive: { color: colors.primaryForeground },
|
||||
chipText: { ...typography.captionMedium, color: c.textSecondary },
|
||||
chipTextActive: { color: c.primaryForeground },
|
||||
chipDivider: {
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: colors.border,
|
||||
backgroundColor: c.border,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: colors.primary },
|
||||
sectionHeaderText: { ...typography.small, color: c.primary },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||
|
||||
errorBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
@@ -484,17 +488,18 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
borderColor: c.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
errorText: { ...typography.caption, color: c.errorForeground },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl * 2,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
|
||||
+221
-118
@@ -6,10 +6,16 @@ import {
|
||||
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
||||
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
||||
} from 'lucide-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';
|
||||
import { useTypography, useDensity } from '../theme/dynamic';
|
||||
import SidebarDrawer from '../components/SidebarDrawer';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { UndoSnackbar } from '../components/UndoSnackbar';
|
||||
import { OfflineBanner } from '../components/OfflineBanner';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import type { Email } from '../api/types';
|
||||
@@ -30,6 +36,10 @@ function isStarred(email: Email): boolean {
|
||||
return !!email.keywords?.$flagged;
|
||||
}
|
||||
|
||||
function isPinned(email: Email): boolean {
|
||||
return !!email.keywords?.$important;
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
@@ -47,17 +57,25 @@ function formatRelativeTime(date: Date): string {
|
||||
|
||||
const EmailRow = React.memo(function EmailRow({
|
||||
item,
|
||||
threadCount,
|
||||
showPreview,
|
||||
onPress,
|
||||
onLongPress,
|
||||
selected,
|
||||
selectionMode,
|
||||
}: {
|
||||
item: Email;
|
||||
threadCount: number;
|
||||
showPreview: boolean;
|
||||
onPress: (id: string) => void;
|
||||
onLongPress: (id: string) => void;
|
||||
selected: boolean;
|
||||
selectionMode: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const dyn = useTypography();
|
||||
const density = useDensity();
|
||||
const senderName = getSenderName(item);
|
||||
const senderEmail = getSenderEmail(item);
|
||||
const unread = isUnread(item);
|
||||
@@ -70,6 +88,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.emailRow,
|
||||
{ paddingVertical: density.rowPaddingVertical },
|
||||
pressed && styles.emailRowPressed,
|
||||
selected && styles.emailRowSelected,
|
||||
]}
|
||||
@@ -80,51 +99,64 @@ const EmailRow = React.memo(function EmailRow({
|
||||
{selectionMode && (
|
||||
<View style={styles.rowCheckboxWrap}>
|
||||
{selected ? (
|
||||
<SquareCheck size={16} color={colors.primary} />
|
||||
<SquareCheck size={16} color={c.primary} />
|
||||
) : (
|
||||
<Square size={16} color={colors.textMuted} />
|
||||
<Square size={16} color={c.textMuted} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
|
||||
{density.showAvatar && (
|
||||
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.emailContent}>
|
||||
{/* Row 1: Sender + indicators + time */}
|
||||
<View style={styles.emailHeaderRow}>
|
||||
<View style={styles.senderRow}>
|
||||
<Text style={[styles.emailFrom, unread && styles.textUnread]} numberOfLines={1}>
|
||||
<Text style={[styles.emailFrom, dyn.bodyMedium, unread && styles.textUnread]} numberOfLines={1}>
|
||||
{senderName}
|
||||
</Text>
|
||||
{starred && (
|
||||
<Star size={componentSizes.statusIcon} color={colors.starred} fill={colors.starred} />
|
||||
<Star size={componentSizes.statusIcon} color={c.starred} fill={c.starred} />
|
||||
)}
|
||||
{item.hasAttachment && (
|
||||
<Paperclip size={componentSizes.statusIcon} color={colors.textMuted} />
|
||||
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.timeAndTag}>
|
||||
<Text style={styles.emailDate}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
|
||||
{threadCount > 1 && (
|
||||
<View style={styles.threadBadge}>
|
||||
<Text style={styles.threadBadgeText}>{threadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.emailDate, dyn.caption]}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Row 2: Subject */}
|
||||
<View style={styles.subjectRow}>
|
||||
<Text style={[styles.emailSubject, unread && styles.textBold]} numberOfLines={1}>
|
||||
<Text style={[styles.emailSubject, dyn.body, unread && styles.textBold]} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Row 3: Preview */}
|
||||
<Text style={styles.emailPreview} numberOfLines={2}>
|
||||
{item.preview}
|
||||
</Text>
|
||||
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
|
||||
{showPreview && density.showPreview && (
|
||||
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
|
||||
{item.preview}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
const EmailRowSeparator = () => <View style={styles.separator} />;
|
||||
function EmailRowSeparator() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return <View style={styles.separator} />;
|
||||
}
|
||||
|
||||
const emailKeyExtractor = (item: Email) => item.id;
|
||||
|
||||
@@ -134,6 +166,8 @@ interface EmailListScreenProps {
|
||||
}
|
||||
|
||||
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
|
||||
const emails = useEmailStore((s) => s.emails);
|
||||
@@ -153,11 +187,43 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const markRead = useEmailStore((s) => s.markRead);
|
||||
const markUnread = useEmailStore((s) => s.markUnread);
|
||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||
const togglePin = useEmailStore((s) => s.togglePin);
|
||||
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
|
||||
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
|
||||
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
|
||||
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
||||
const showPreview = useSettingsStore((s) => s.showPreview);
|
||||
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
||||
const networkOnline = useNetworkStore((s) => s.online);
|
||||
|
||||
// When threading is on, collapse same-thread emails so the list shows the
|
||||
// most recent message per thread with a count badge. Disabling threading
|
||||
// falls back to the flat list (every message is its own row).
|
||||
const visibleEmails = React.useMemo(() => {
|
||||
if (disableThreading) return emails;
|
||||
const seen = new Set<string>();
|
||||
const out: Email[] = [];
|
||||
for (const e of emails) {
|
||||
const key = e.threadId || e.id;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(e);
|
||||
}
|
||||
return out;
|
||||
}, [emails, disableThreading]);
|
||||
|
||||
const threadCounts = React.useMemo(() => {
|
||||
if (disableThreading) return new Map<string, number>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const e of emails) {
|
||||
const key = e.threadId || e.id;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [emails, disableThreading]);
|
||||
|
||||
const archiveMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
|
||||
@@ -172,11 +238,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
[mailboxes],
|
||||
);
|
||||
|
||||
// Move-to-folder picker triggered by the swipe action.
|
||||
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
|
||||
|
||||
// Selection state
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
const selectionMode = selectedIds.size > 0;
|
||||
const allSelected =
|
||||
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
|
||||
visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(e.id));
|
||||
|
||||
// Refs so row press handlers stay referentially stable across renders.
|
||||
// FlatList rows then skip re-render when the parent re-renders for unrelated
|
||||
@@ -213,7 +282,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
switch (action) {
|
||||
case 'archive':
|
||||
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||
void moveToMailboxAction(id, currentMailboxId, archiveMailboxId);
|
||||
void archiveEmailAction(id);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
@@ -231,12 +300,19 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
else void markUnread(id);
|
||||
break;
|
||||
case 'star':
|
||||
void toggleStar(id);
|
||||
void toggleStar(id, !isStarred(email));
|
||||
break;
|
||||
case 'pin':
|
||||
void togglePin(id, !isPinned(email));
|
||||
break;
|
||||
case 'move':
|
||||
setPendingMoveId(id);
|
||||
break;
|
||||
}
|
||||
}, [
|
||||
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
|
||||
moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar,
|
||||
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
|
||||
markRead, markUnread, toggleStar, togglePin,
|
||||
]);
|
||||
|
||||
const renderEmailRow = React.useCallback(
|
||||
@@ -244,11 +320,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<SwipeableRow
|
||||
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
||||
rightAction={selectionMode ? 'none' : swipeRightAction}
|
||||
context={{ unread: isUnread(item), starred: isStarred(item) }}
|
||||
mode={swipeMode}
|
||||
context={{ unread: isUnread(item), starred: isStarred(item), pinned: isPinned(item) }}
|
||||
onAction={(action) => handleSwipeAction(item.id, action)}
|
||||
>
|
||||
<EmailRow
|
||||
item={item}
|
||||
threadCount={threadCounts.get(item.threadId || item.id) ?? 1}
|
||||
showPreview={showPreview}
|
||||
selected={selectedIds.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onPress={handleRowPress}
|
||||
@@ -256,7 +335,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
/>
|
||||
</SwipeableRow>
|
||||
),
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction],
|
||||
);
|
||||
|
||||
const clearSelection = React.useCallback(() => {
|
||||
@@ -265,11 +344,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
|
||||
const toggleSelectAllVisible = React.useCallback(() => {
|
||||
setSelectedIds((prev) => {
|
||||
const allCurrent = emails.length > 0 && emails.every((e) => prev.has(e.id));
|
||||
const allCurrent = visibleEmails.length > 0 && visibleEmails.every((e) => prev.has(e.id));
|
||||
if (allCurrent) return new Set();
|
||||
return new Set(emails.map((e) => e.id));
|
||||
return new Set(visibleEmails.map((e) => e.id));
|
||||
});
|
||||
}, [emails]);
|
||||
}, [visibleEmails]);
|
||||
|
||||
// Clear selection when mailbox changes
|
||||
React.useEffect(() => {
|
||||
@@ -277,8 +356,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}, [currentMailboxId]);
|
||||
|
||||
const selectedEmails = React.useMemo(
|
||||
() => emails.filter((e) => selectedIds.has(e.id)),
|
||||
[emails, selectedIds],
|
||||
() => visibleEmails.filter((e) => selectedIds.has(e.id)),
|
||||
[visibleEmails, selectedIds],
|
||||
);
|
||||
const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e));
|
||||
const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e));
|
||||
@@ -382,7 +461,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
{selectionMode ? (
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={clearSelection} style={styles.headerButton}>
|
||||
<X size={20} color={colors.text} />
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selectedIds.size} selected</Text>
|
||||
<Pressable
|
||||
@@ -392,8 +471,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
>
|
||||
<Star
|
||||
size={20}
|
||||
color={allSelectedAreStarred ? colors.starred : colors.text}
|
||||
fill={allSelectedAreStarred ? colors.starred : 'transparent'}
|
||||
color={allSelectedAreStarred ? c.starred : c.text}
|
||||
fill={allSelectedAreStarred ? c.starred : 'transparent'}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -402,9 +481,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
hitSlop={6}
|
||||
>
|
||||
{allSelectedAreRead ? (
|
||||
<MailIcon size={20} color={colors.text} />
|
||||
<MailIcon size={20} color={c.text} />
|
||||
) : (
|
||||
<MailOpen size={20} color={colors.text} />
|
||||
<MailOpen size={20} color={c.text} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -412,13 +491,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
style={styles.headerButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Trash2 size={20} color={colors.text} />
|
||||
<Trash2 size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
|
||||
<Menu size={20} color={colors.textMuted} />
|
||||
<Menu size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
@@ -434,21 +513,21 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<View style={styles.searchBar}>
|
||||
<Pressable style={styles.checkboxButton} onPress={toggleSelectAllVisible} hitSlop={6}>
|
||||
{allSelected ? (
|
||||
<SquareCheck size={18} color={colors.primary} />
|
||||
<SquareCheck size={18} color={c.primary} />
|
||||
) : selectionMode ? (
|
||||
<View style={styles.checkboxIndeterminate}>
|
||||
<Minus size={14} color={colors.background} />
|
||||
<Minus size={14} color={c.background} />
|
||||
</View>
|
||||
) : (
|
||||
<Square size={18} color={colors.textMuted} />
|
||||
<Square size={18} color={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.searchInputArea}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<Search size={16} color={c.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search mail..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={searchInput}
|
||||
onChangeText={setSearchInput}
|
||||
autoCapitalize="none"
|
||||
@@ -461,7 +540,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
hitSlop={8}
|
||||
style={styles.searchClearButton}
|
||||
>
|
||||
<X size={14} color={colors.textMuted} />
|
||||
<X size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -469,7 +548,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
|
||||
onPress={() => setFilterMenuOpen(true)}
|
||||
>
|
||||
<Filter size={18} color={activeFilterCount > 0 ? colors.primary : colors.textMuted} />
|
||||
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
|
||||
{activeFilterCount > 0 && (
|
||||
<View style={styles.filterBadge}>
|
||||
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
|
||||
@@ -482,7 +561,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<View style={styles.filterChipsRow}>
|
||||
{storeSearchQuery ? (
|
||||
<View style={styles.chip}>
|
||||
<Search size={12} color={colors.textSecondary} />
|
||||
<Search size={12} color={c.textSecondary} />
|
||||
<Text style={styles.chipText} numberOfLines={1}>
|
||||
{storeSearchQuery}
|
||||
</Text>
|
||||
@@ -505,35 +584,35 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
) : null}
|
||||
{filters.dateAfter ? (
|
||||
<View style={styles.chip}>
|
||||
<CalendarDays size={12} color={colors.textSecondary} />
|
||||
<CalendarDays size={12} color={c.textSecondary} />
|
||||
<Text style={styles.chipText}>After {filters.dateAfter}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{filters.dateBefore ? (
|
||||
<View style={styles.chip}>
|
||||
<CalendarDays size={12} color={colors.textSecondary} />
|
||||
<CalendarDays size={12} color={c.textSecondary} />
|
||||
<Text style={styles.chipText}>Before {filters.dateBefore}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{filters.isUnread !== undefined && (
|
||||
<View style={styles.chip}>
|
||||
{filters.isUnread ? (
|
||||
<MailIcon size={12} color={colors.textSecondary} />
|
||||
<MailIcon size={12} color={c.textSecondary} />
|
||||
) : (
|
||||
<MailOpen size={12} color={colors.textSecondary} />
|
||||
<MailOpen size={12} color={c.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.chipText}>{filters.isUnread ? 'Unread' : 'Read'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{filters.isStarred !== undefined && (
|
||||
<View style={styles.chip}>
|
||||
<Star size={12} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />
|
||||
<Star size={12} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />
|
||||
<Text style={styles.chipText}>{filters.isStarred ? 'Starred' : 'Not starred'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{filters.hasAttachment !== undefined && (
|
||||
<View style={styles.chip}>
|
||||
<Paperclip size={12} color={colors.textSecondary} />
|
||||
<Paperclip size={12} color={c.textSecondary} />
|
||||
<Text style={styles.chipText}>
|
||||
{filters.hasAttachment ? 'Has attachment' : 'No attachment'}
|
||||
</Text>
|
||||
@@ -552,15 +631,19 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
</View>
|
||||
)}
|
||||
|
||||
<OfflineBanner hint={emails.length > 0 ? 'Showing cached mail' : undefined} />
|
||||
|
||||
{/* Email list */}
|
||||
{loading && emails.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<ActivityIndicator color={c.primary} />
|
||||
<Text style={styles.loadingText}>Loading emails...</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
) : error && emails.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{networkOnline ? error : 'No connection. Showing nothing because no mail has been cached yet.'}
|
||||
</Text>
|
||||
<Pressable onPress={() => { void refreshEmails(); }}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
@@ -585,7 +668,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={emails}
|
||||
data={visibleEmails}
|
||||
keyExtractor={emailKeyExtractor}
|
||||
renderItem={renderEmailRow}
|
||||
ItemSeparatorComponent={EmailRowSeparator}
|
||||
@@ -602,7 +685,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
onPress={onComposePress}
|
||||
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
||||
>
|
||||
<SquarePen size={24} color={colors.background} />
|
||||
<SquarePen size={24} color={c.background} />
|
||||
</Pressable>
|
||||
|
||||
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
@@ -625,7 +708,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
style={styles.filterMenuHeaderBtn}
|
||||
hitSlop={6}
|
||||
>
|
||||
<RotateCcw size={12} color={colors.textSecondary} />
|
||||
<RotateCcw size={12} color={c.textSecondary} />
|
||||
<Text style={styles.filterMenuHeaderBtnText}>Clear</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -633,7 +716,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
style={styles.filterMenuClose}
|
||||
hitSlop={6}
|
||||
>
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
<X size={16} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -646,7 +729,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
value={filters.from ?? ''}
|
||||
onChangeText={(v) => setFilterField('from', v)}
|
||||
placeholder="sender@example.com"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.filterFieldInput}
|
||||
@@ -658,7 +741,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
value={filters.to ?? ''}
|
||||
onChangeText={(v) => setFilterField('to', v)}
|
||||
placeholder="recipient@example.com"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.filterFieldInput}
|
||||
@@ -672,7 +755,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
value={filters.subject ?? ''}
|
||||
onChangeText={(v) => setFilterField('subject', v)}
|
||||
placeholder="Subject contains..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.filterFieldInput}
|
||||
/>
|
||||
</View>
|
||||
@@ -684,7 +767,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
style={styles.filterDateButton}
|
||||
onPress={() => setDatePickerField('dateAfter')}
|
||||
>
|
||||
<CalendarDays size={14} color={colors.textMuted} />
|
||||
<CalendarDays size={14} color={c.textMuted} />
|
||||
<Text style={[styles.filterDateText, !filters.dateAfter && styles.filterDateTextEmpty]}>
|
||||
{filters.dateAfter || 'YYYY-MM-DD'}
|
||||
</Text>
|
||||
@@ -693,7 +776,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
onPress={() => setFilterField('dateAfter', undefined)}
|
||||
hitSlop={6}
|
||||
>
|
||||
<X size={14} color={colors.textMuted} />
|
||||
<X size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -704,7 +787,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
style={styles.filterDateButton}
|
||||
onPress={() => setDatePickerField('dateBefore')}
|
||||
>
|
||||
<CalendarDays size={14} color={colors.textMuted} />
|
||||
<CalendarDays size={14} color={c.textMuted} />
|
||||
<Text style={[styles.filterDateText, !filters.dateBefore && styles.filterDateTextEmpty]}>
|
||||
{filters.dateBefore || 'YYYY-MM-DD'}
|
||||
</Text>
|
||||
@@ -713,7 +796,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
onPress={() => setFilterField('dateBefore', undefined)}
|
||||
hitSlop={6}
|
||||
>
|
||||
<X size={14} color={colors.textMuted} />
|
||||
<X size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -722,13 +805,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
|
||||
<View style={styles.filterToggleGroup}>
|
||||
<TriToggle
|
||||
icon={<Paperclip size={14} color={colors.textSecondary} />}
|
||||
icon={<Paperclip size={14} color={c.textSecondary} />}
|
||||
label="Has attachment"
|
||||
value={filters.hasAttachment}
|
||||
onPress={() => cycleTriState('hasAttachment')}
|
||||
/>
|
||||
<TriToggle
|
||||
icon={<Star size={14} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />}
|
||||
icon={<Star size={14} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />}
|
||||
label="Starred"
|
||||
value={filters.isStarred}
|
||||
onPress={() => cycleTriState('isStarred')}
|
||||
@@ -736,9 +819,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<TriToggle
|
||||
icon={
|
||||
filters.isUnread === false ? (
|
||||
<MailOpen size={14} color={colors.textSecondary} />
|
||||
<MailOpen size={14} color={c.textSecondary} />
|
||||
) : (
|
||||
<MailIcon size={14} color={colors.textSecondary} />
|
||||
<MailIcon size={14} color={c.textSecondary} />
|
||||
)
|
||||
}
|
||||
label={filters.isUnread === false ? 'Read' : 'Unread'}
|
||||
@@ -793,6 +876,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
<MoveSheet
|
||||
visible={pendingMoveId !== null}
|
||||
onClose={() => setPendingMoveId(null)}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={(toId) => {
|
||||
const id = pendingMoveId;
|
||||
setPendingMoveId(null);
|
||||
if (id && currentMailboxId && toId !== currentMailboxId) {
|
||||
void moveToMailboxAction(id, currentMailboxId, toId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<UndoSnackbar />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -808,6 +907,8 @@ function TriToggle({
|
||||
value: boolean | undefined;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const state =
|
||||
value === true ? 'on' : value === false ? 'off' : 'unset';
|
||||
return (
|
||||
@@ -833,8 +934,9 @@ function TriToggle({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
|
||||
// Header - matches web mobile-header: h-14 (56px), px-4, border-b
|
||||
header: {
|
||||
@@ -843,7 +945,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
height: componentSizes.headerHeight,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.md,
|
||||
},
|
||||
headerButton: {
|
||||
@@ -851,7 +953,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1 }, // text-lg font-semibold
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
|
||||
headerLogo: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -876,13 +978,13 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight, // h-10 = 40px
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.text },
|
||||
searchInput: { flex: 1, ...typography.body, color: c.text },
|
||||
filterButton: {
|
||||
width: componentSizes.buttonMd, height: componentSizes.buttonMd,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
@@ -890,7 +992,7 @@ const styles = StyleSheet.create({
|
||||
position: 'relative',
|
||||
},
|
||||
filterButtonActive: {
|
||||
backgroundColor: colors.accent,
|
||||
backgroundColor: c.accent,
|
||||
},
|
||||
filterBadge: {
|
||||
position: 'absolute',
|
||||
@@ -899,7 +1001,7 @@ const styles = StyleSheet.create({
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 4,
|
||||
@@ -907,7 +1009,7 @@ const styles = StyleSheet.create({
|
||||
filterBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: colors.primaryForeground,
|
||||
color: c.primaryForeground,
|
||||
lineHeight: 14,
|
||||
},
|
||||
searchClearButton: {
|
||||
@@ -929,9 +1031,9 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 4,
|
||||
@@ -939,7 +1041,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
chipText: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
clearAllButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
@@ -947,7 +1049,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
clearAllText: {
|
||||
...typography.captionMedium,
|
||||
color: colors.primary,
|
||||
color: c.primary,
|
||||
},
|
||||
filterBackdrop: {
|
||||
flex: 1,
|
||||
@@ -960,10 +1062,10 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
maxHeight: '85%',
|
||||
backgroundColor: colors.popover,
|
||||
backgroundColor: c.popover,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
filterMenuHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -973,11 +1075,11 @@ const styles = StyleSheet.create({
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
filterMenuTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
filterMenuHeaderActions: {
|
||||
flexDirection: 'row',
|
||||
@@ -994,7 +1096,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
filterMenuHeaderBtnText: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
filterMenuClose: {
|
||||
width: 28,
|
||||
@@ -1014,15 +1116,15 @@ const styles = StyleSheet.create({
|
||||
filterFieldHalf: { flex: 1 },
|
||||
filterFieldLabel: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
marginBottom: 4,
|
||||
},
|
||||
filterFieldInput: {
|
||||
...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: 6,
|
||||
@@ -1032,20 +1134,20 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 32,
|
||||
},
|
||||
filterDateText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
flex: 1,
|
||||
},
|
||||
filterDateTextEmpty: {
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
filterToggleGroup: {
|
||||
flexDirection: 'row',
|
||||
@@ -1062,26 +1164,26 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
triToggleOn: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
triToggleOff: {
|
||||
backgroundColor: colors.muted,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: c.muted,
|
||||
borderColor: c.border,
|
||||
},
|
||||
triToggleText: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
triToggleTextOn: {
|
||||
color: colors.primary,
|
||||
color: c.primary,
|
||||
},
|
||||
triToggleTextOff: {
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
pickerOverlay: {
|
||||
@@ -1093,12 +1195,12 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: colors.popover,
|
||||
backgroundColor: c.popover,
|
||||
paddingBottom: spacing.xl,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
},
|
||||
pickerHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -1106,11 +1208,11 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
pickerDone: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.primary,
|
||||
color: c.primary,
|
||||
},
|
||||
|
||||
// Email list - matches web email-list-item
|
||||
@@ -1121,10 +1223,10 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.md, // py-3 = 12px (density-item-py)
|
||||
gap: spacing.md, // gap-3 = 12px (density-item-gap)
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border, // border-b border-border
|
||||
borderBottomColor: c.border, // border-b border-border
|
||||
},
|
||||
emailRowPressed: { backgroundColor: colors.surface },
|
||||
emailRowSelected: { backgroundColor: colors.selection },
|
||||
emailRowPressed: { backgroundColor: c.surface },
|
||||
emailRowSelected: { backgroundColor: c.selection },
|
||||
rowCheckboxWrap: {
|
||||
width: 16,
|
||||
height: componentSizes.avatarMd,
|
||||
@@ -1136,7 +1238,7 @@ const styles = StyleSheet.create({
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -1155,7 +1257,7 @@ const styles = StyleSheet.create({
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
// Sender: text-sm, font-medium (read) / font-bold (unread)
|
||||
emailFrom: { ...typography.bodyMedium, color: colors.textSecondary, flexShrink: 1 },
|
||||
emailFrom: { ...typography.bodyMedium, color: c.textSecondary, flexShrink: 1 },
|
||||
timeAndTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1166,20 +1268,20 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
},
|
||||
threadBadgeText: { ...typography.caption, color: colors.textMuted },
|
||||
threadBadgeText: { ...typography.caption, color: c.textMuted },
|
||||
// Date: text-xs (12px), tabular-nums
|
||||
emailDate: { ...typography.caption, color: colors.textMuted },
|
||||
emailDate: { ...typography.caption, color: c.textMuted },
|
||||
subjectRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
// Subject: text-sm (14px), font-semibold (unread) / font-normal (read)
|
||||
emailSubject: { ...typography.body, color: colors.text, flex: 1 },
|
||||
emailSubject: { ...typography.body, color: c.text, flex: 1 },
|
||||
// Preview: text-sm, leading-relaxed, line-clamp-2
|
||||
emailPreview: { ...typography.body, color: colors.textSecondary, lineHeight: 20, opacity: 0.8 },
|
||||
emailPreview: { ...typography.body, color: c.textSecondary, lineHeight: 20, opacity: 0.8 },
|
||||
// Unread state: text foreground + font-bold
|
||||
textUnread: { fontWeight: '600', color: colors.text },
|
||||
textUnread: { fontWeight: '600', color: c.text },
|
||||
textBold: { fontWeight: '700' },
|
||||
// Tag pill: text-[10px], rounded-full, px-1.5 py-0.5, gap-1
|
||||
tagPill: {
|
||||
@@ -1206,7 +1308,7 @@ const styles = StyleSheet.create({
|
||||
width: componentSizes.fab, // 56px (h-14)
|
||||
height: componentSizes.fab, // 56px (w-14)
|
||||
borderRadius: radius.full, // rounded-full (circle)
|
||||
backgroundColor: colors.text, // white - matches webmail mobile FAB
|
||||
backgroundColor: c.text, // white - matches webmail mobile FAB
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 6,
|
||||
@@ -1227,26 +1329,27 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
loadingText: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
errorText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
color: c.error,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
retryText: {
|
||||
...typography.bodyMedium,
|
||||
color: colors.primary,
|
||||
color: c.primary,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
hintText: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
marginTop: spacing.xs,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type { EmailListScreenProps };
|
||||
|
||||
@@ -7,11 +7,14 @@ import { ArrowLeft, Share2 } from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import { fetchRawEmail, shareEmailEml } from '../lib/email-export';
|
||||
import { colors, spacing, typography, componentSizes, radius } from '../theme/tokens';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
|
||||
|
||||
export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { blobId, subject } = route.params;
|
||||
const [raw, setRaw] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -41,19 +44,19 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={colors.text} />
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{subject || 'Email source'}
|
||||
</Text>
|
||||
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!raw}>
|
||||
<Share2 size={20} color={raw ? colors.text : colors.textMuted} />
|
||||
<Share2 size={20} color={raw ? c.text : c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!raw && !error ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.loading}>
|
||||
@@ -68,28 +71,30 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
error: { ...typography.body, color: colors.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
|
||||
error: { ...typography.body, color: c.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
|
||||
body: { padding: spacing.lg },
|
||||
source: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+252
-239
@@ -9,31 +9,22 @@ import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, Tag,
|
||||
Code, Download, Tag,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-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';
|
||||
import EmailBodyView from '../components/EmailBodyView';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { setEmailKeywords } from '../api/email';
|
||||
import { shareEmailEml } from '../lib/email-export';
|
||||
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import type { Email, Mailbox } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/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;
|
||||
}
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
|
||||
|
||||
function formatHeaderDate(iso: string): string {
|
||||
@@ -60,6 +51,8 @@ function plainTextBody(email: Email): string {
|
||||
}
|
||||
|
||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { emailId, subject: subjectParam } = route.params;
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
@@ -67,6 +60,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const markRead = useEmailStore((s) => s.markRead);
|
||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const emails = useEmailStore((s) => s.emails);
|
||||
@@ -74,11 +68,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const currentIndex = emails.findIndex((e) => e.id === emailId);
|
||||
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
|
||||
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
|
||||
const goToEmail = (target: { id: string; threadId: string; subject: string }) => {
|
||||
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
|
||||
navigation.replace('EmailThread', {
|
||||
emailId: target.id,
|
||||
threadId: target.threadId,
|
||||
subject: target.subject,
|
||||
subject: target.subject ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -88,6 +82,90 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
||||
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
|
||||
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
|
||||
|
||||
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
|
||||
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
|
||||
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
|
||||
const onPressAttachment = React.useCallback(
|
||||
async (blobId: string, name: string | undefined, type: string | undefined) => {
|
||||
if (downloadingBlobId) return;
|
||||
setDownloadingBlobId(blobId);
|
||||
try {
|
||||
if (mailAttachmentAction === 'download') {
|
||||
await downloadAttachment(blobId, name, type);
|
||||
} else {
|
||||
await shareAttachment(blobId, name, type);
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDownloadingBlobId(null);
|
||||
}
|
||||
},
|
||||
[downloadingBlobId, mailAttachmentAction],
|
||||
);
|
||||
|
||||
const renderAttachments = () => {
|
||||
const all = email?.attachments;
|
||||
if (!all || all.length === 0) return null;
|
||||
// hideInlineImageAttachments: drop chips for images already shown inline
|
||||
// in the body (any image attachment with a cid:). Non-image inline
|
||||
// attachments stay visible because the user wouldn't see them otherwise.
|
||||
const atts = hideInlineImageAttachments
|
||||
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
|
||||
: all;
|
||||
if (atts.length === 0) return null;
|
||||
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
|
||||
return (
|
||||
<View style={styles.attachmentsBlock}>
|
||||
<View style={styles.attachmentsRow}>
|
||||
{visible.map((att, idx) => {
|
||||
const isDownloading = downloadingBlobId === att.blobId;
|
||||
return (
|
||||
<Pressable
|
||||
key={att.blobId ?? idx}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentChip,
|
||||
pressed && styles.attachmentChipPressed,
|
||||
]}
|
||||
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
|
||||
disabled={!!downloadingBlobId}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Paperclip size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||
{att.name || 'attachment'}
|
||||
</Text>
|
||||
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{atts.length > 3 && (
|
||||
<Pressable
|
||||
onPress={() => setAttachmentsExpanded((v) => !v)}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentsToggle,
|
||||
pressed && styles.attachmentsTogglePressed,
|
||||
]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.attachmentsToggleText}>
|
||||
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
@@ -95,6 +173,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let readTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
@@ -103,7 +182,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
if (cancelled) return;
|
||||
setEmail(fetched);
|
||||
if (fetched && !fetched.keywords?.$seen) {
|
||||
void markRead(emailId);
|
||||
if (markAsReadDelay > 0) {
|
||||
readTimer = setTimeout(() => {
|
||||
if (!cancelled) void markRead(emailId);
|
||||
}, markAsReadDelay);
|
||||
} else {
|
||||
void markRead(emailId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
@@ -113,8 +198,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [emailId, getEmailDetail, markRead]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (readTimer) clearTimeout(readTimer);
|
||||
};
|
||||
}, [emailId, getEmailDetail, markRead, markAsReadDelay]);
|
||||
|
||||
const starred = !!email?.keywords?.$flagged;
|
||||
const unread = !!email && !email.keywords?.$seen;
|
||||
@@ -179,7 +267,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const onArchive = () => {
|
||||
if (!email || !currentMailboxId || !archiveMailbox) return;
|
||||
if (currentMailboxId === archiveMailbox.id) return;
|
||||
void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id);
|
||||
void archiveEmailAction(email.id);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
@@ -236,17 +324,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
{/* Toolbar */}
|
||||
<View style={styles.toolbar}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.toolbarBack} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={colors.text} />
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={styles.toolbarActions}>
|
||||
<ToolbarButton
|
||||
icon={<Trash2 size={18} color={colors.textSecondary} />}
|
||||
icon={<Trash2 size={18} color={c.textSecondary} />}
|
||||
label="Delete"
|
||||
onPress={onDelete}
|
||||
/>
|
||||
{showArchive && (
|
||||
<ToolbarButton
|
||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
||||
icon={<Archive size={18} color={c.textSecondary} />}
|
||||
label="Archive"
|
||||
onPress={onArchive}
|
||||
/>
|
||||
@@ -255,9 +343,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<ToolbarButton
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpen size={18} color={colors.textSecondary} />
|
||||
<MailOpen size={18} color={c.textSecondary} />
|
||||
) : (
|
||||
<Mail size={18} color={colors.textSecondary} />
|
||||
<Mail size={18} color={c.textSecondary} />
|
||||
)
|
||||
}
|
||||
label={unread ? 'Read' : 'Unread'}
|
||||
@@ -268,15 +356,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
icon={
|
||||
<Star
|
||||
size={18}
|
||||
color={starred ? colors.starred : colors.textSecondary}
|
||||
fill={starred ? colors.starred : 'transparent'}
|
||||
color={starred ? c.starred : c.textSecondary}
|
||||
fill={starred ? c.starred : 'transparent'}
|
||||
/>
|
||||
}
|
||||
label="Star"
|
||||
onPress={onToggleStar}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<MoreVertical size={18} color={colors.textSecondary} />}
|
||||
icon={<MoreVertical size={18} color={c.textSecondary} />}
|
||||
label="More"
|
||||
onPress={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
@@ -285,7 +373,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.centered}>
|
||||
@@ -304,8 +392,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
|
||||
<Star
|
||||
size={18}
|
||||
color={starred ? colors.starred : colors.textMuted}
|
||||
fill={starred ? colors.starred : 'transparent'}
|
||||
color={starred ? c.starred : c.textMuted}
|
||||
fill={starred ? c.starred : 'transparent'}
|
||||
/>
|
||||
</Pressable>
|
||||
<Text style={styles.subjectText}>{subject}</Text>
|
||||
@@ -340,24 +428,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{attachmentPosition === 'beside-sender' && renderAttachments()}
|
||||
</View>
|
||||
|
||||
{/* Attachments chips */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<View style={styles.attachmentsBlock}>
|
||||
<View style={styles.attachmentsRow}>
|
||||
{email.attachments.map((att, idx) => (
|
||||
<View key={att.blobId ?? idx} style={styles.attachmentChip}>
|
||||
<Paperclip size={14} color={colors.textMuted} />
|
||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||
{att.name || 'attachment'}
|
||||
</Text>
|
||||
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Attachments chips (full-width below header) */}
|
||||
{attachmentPosition === 'below-header' && renderAttachments()}
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.bodyBlock}>
|
||||
@@ -368,28 +443,28 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
{/* Bottom action bar */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
|
||||
<BottomBarButton
|
||||
icon={<ChevronLeft size={20} color={colors.textMuted} />}
|
||||
icon={<ChevronLeft size={20} color={c.textMuted} />}
|
||||
label="Prev"
|
||||
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
|
||||
disabled={!prevEmail}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<Reply size={20} color={colors.textSecondary} />}
|
||||
icon={<Reply size={20} color={c.textSecondary} />}
|
||||
label="Reply"
|
||||
onPress={() => navigateCompose('reply')}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<ReplyAll size={20} color={colors.textSecondary} />}
|
||||
icon={<ReplyAll size={20} color={c.textSecondary} />}
|
||||
label="Reply All"
|
||||
onPress={() => navigateCompose('replyAll')}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<Forward size={20} color={colors.textSecondary} />}
|
||||
icon={<Forward size={20} color={c.textSecondary} />}
|
||||
label="Forward"
|
||||
onPress={() => navigateCompose('forward')}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<ChevronRight size={20} color={colors.textMuted} />}
|
||||
icon={<ChevronRight size={20} color={c.textMuted} />}
|
||||
label="Next"
|
||||
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
|
||||
disabled={!nextEmail}
|
||||
@@ -436,7 +511,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<MoveMenuSheet
|
||||
<MoveSheet
|
||||
visible={moveMenuOpen}
|
||||
onClose={() => setMoveMenuOpen(false)}
|
||||
mailboxes={mailboxes}
|
||||
@@ -481,9 +556,12 @@ function MoreMenuSheet({
|
||||
showSpam, isInJunk, canViewSource, canExport,
|
||||
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onViewSource, onExport,
|
||||
}: MoreMenuSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(400)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 400, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -510,16 +588,20 @@ function MoreMenuSheet({
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>More actions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>More actions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{canArchive && (
|
||||
<MoreMenuItem
|
||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
||||
icon={<Archive size={18} color={c.textSecondary} />}
|
||||
label="Archive"
|
||||
onPress={onArchive}
|
||||
/>
|
||||
@@ -528,9 +610,9 @@ function MoreMenuSheet({
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpen size={18} color={colors.textSecondary} />
|
||||
<MailOpen size={18} color={c.textSecondary} />
|
||||
) : (
|
||||
<Mail size={18} color={colors.textSecondary} />
|
||||
<Mail size={18} color={c.textSecondary} />
|
||||
)
|
||||
}
|
||||
label={unread ? 'Mark as read' : 'Mark as unread'}
|
||||
@@ -539,27 +621,27 @@ function MoreMenuSheet({
|
||||
)}
|
||||
{canMove && (
|
||||
<MoreMenuItem
|
||||
icon={<FolderInput size={18} color={colors.textSecondary} />}
|
||||
icon={<FolderInput size={18} color={c.textSecondary} />}
|
||||
label="Move to folder…"
|
||||
onPress={onMove}
|
||||
trailing={<ChevronRight size={16} color={colors.textMuted} />}
|
||||
trailing={<ChevronRight size={16} color={c.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{canTag && (
|
||||
<MoreMenuItem
|
||||
icon={<Tag size={18} color={colors.textSecondary} />}
|
||||
icon={<Tag size={18} color={c.textSecondary} />}
|
||||
label="Tag…"
|
||||
onPress={onTag}
|
||||
trailing={<ChevronRight size={16} color={colors.textMuted} />}
|
||||
trailing={<ChevronRight size={16} color={c.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{showSpam && (
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
isInJunk ? (
|
||||
<ShieldCheck size={18} color={colors.success} />
|
||||
<ShieldCheck size={18} color={c.success} />
|
||||
) : (
|
||||
<ShieldAlert size={18} color={colors.error} />
|
||||
<ShieldAlert size={18} color={c.error} />
|
||||
)
|
||||
}
|
||||
label={isInJunk ? 'Not spam' : 'Mark as spam'}
|
||||
@@ -568,14 +650,14 @@ function MoreMenuSheet({
|
||||
)}
|
||||
{canViewSource && (
|
||||
<MoreMenuItem
|
||||
icon={<Code size={18} color={colors.textSecondary} />}
|
||||
icon={<Code size={18} color={c.textSecondary} />}
|
||||
label="View source"
|
||||
onPress={onViewSource}
|
||||
/>
|
||||
)}
|
||||
{canExport && (
|
||||
<MoreMenuItem
|
||||
icon={<Download size={18} color={colors.textSecondary} />}
|
||||
icon={<Download size={18} color={c.textSecondary} />}
|
||||
label="Export email (.eml)"
|
||||
onPress={onExport}
|
||||
/>
|
||||
@@ -594,9 +676,12 @@ interface TagMenuSheetProps {
|
||||
}
|
||||
|
||||
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
|
||||
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) {
|
||||
@@ -623,29 +708,33 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Tags</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Tags</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={{ ...typography.body, color: colors.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
||||
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
||||
No tags yet. Add some in Settings → Keywords & Labels.
|
||||
</Text>
|
||||
) : (
|
||||
keywords.map((kw) => {
|
||||
const token = keywordToken(kw.id);
|
||||
const active = !!activeKeywords[token];
|
||||
const palette = colors.tags[kw.color];
|
||||
const palette = c.tags[kw.color];
|
||||
return (
|
||||
<MoreMenuItem
|
||||
key={kw.id}
|
||||
icon={<View style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: palette.dot }} />}
|
||||
label={kw.label}
|
||||
onPress={() => onToggle(token)}
|
||||
trailing={active ? <Check size={16} color={colors.primary} /> : null}
|
||||
trailing={active ? <Check size={16} color={c.primary} /> : null}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -655,103 +744,11 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
|
||||
);
|
||||
}
|
||||
|
||||
interface MoveMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
mailboxes: Mailbox[];
|
||||
currentMailboxId: string | null;
|
||||
onPick: (id: string) => void;
|
||||
}
|
||||
|
||||
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
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,
|
||||
styles.sheetTall,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Move to folder</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</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 ? colors.textSecondary : colors.textMuted} />
|
||||
<Text
|
||||
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreMenuItem({
|
||||
icon, label, onPress, trailing,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -767,10 +764,18 @@ function MoreMenuItem({
|
||||
function ToolbarButton({
|
||||
icon, label, onPress,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const showLabels = useSettingsStore((s) => s.showToolbarLabels);
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.toolbarAction} hitSlop={6}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={styles.toolbarAction}
|
||||
hitSlop={6}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
{icon}
|
||||
<Text style={styles.toolbarActionLabel}>{label}</Text>
|
||||
{showLabels && <Text style={styles.toolbarActionLabel}>{label}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -778,22 +783,29 @@ function ToolbarButton({
|
||||
function BottomBarButton({
|
||||
icon, label, onPress, disabled,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const showLabels = useSettingsStore((s) => s.showToolbarLabels);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={disabled ? undefined : onPress}
|
||||
style={[styles.bottomBarBtn, disabled && styles.bottomBarBtnDisabled]}
|
||||
hitSlop={4}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
{icon}
|
||||
<Text style={[styles.bottomBarLabel, disabled && styles.bottomBarLabelDisabled]}>
|
||||
{label}
|
||||
</Text>
|
||||
{showLabels && (
|
||||
<Text style={[styles.bottomBarLabel, disabled && styles.bottomBarLabelDisabled]}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
|
||||
// Top toolbar
|
||||
toolbar: {
|
||||
@@ -803,9 +815,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
toolbarBack: {
|
||||
width: componentSizes.avatarSm,
|
||||
@@ -828,7 +840,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
toolbarActionLabel: {
|
||||
...typography.small,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
|
||||
// Content
|
||||
@@ -838,14 +850,14 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
errorText: { ...typography.body, color: colors.error, textAlign: 'center' },
|
||||
scroll: { flex: 1, backgroundColor: colors.background },
|
||||
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||
scroll: { flex: 1, backgroundColor: c.background },
|
||||
|
||||
// Subject block
|
||||
subjectBlock: {
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
@@ -868,7 +880,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
lineHeight: 28,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
subjectMeta: {
|
||||
@@ -876,19 +888,19 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
subjectDate: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
subjectSize: {
|
||||
...typography.caption,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Sender block
|
||||
senderBlock: {
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
@@ -900,27 +912,27 @@ const styles = StyleSheet.create({
|
||||
senderInfo: { flex: 1, minWidth: 0 },
|
||||
senderName: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
senderEmail: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
senderRecipients: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
senderRecipientsLabel: {
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
|
||||
// Attachments
|
||||
attachmentsBlock: {
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
@@ -935,24 +947,42 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
attachmentChipPressed: {
|
||||
backgroundColor: c.surfaceHover,
|
||||
opacity: 0.85,
|
||||
},
|
||||
attachmentName: {
|
||||
...typography.caption,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
maxWidth: 180,
|
||||
},
|
||||
attachmentSize: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
attachmentsToggle: {
|
||||
marginTop: spacing.xs,
|
||||
alignSelf: 'flex-start',
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
attachmentsTogglePressed: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
attachmentsToggleText: {
|
||||
...typography.caption,
|
||||
color: c.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Body
|
||||
bodyBlock: {
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
|
||||
// Bottom bar
|
||||
@@ -963,9 +993,9 @@ const styles = StyleSheet.create({
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
bottomBarBtn: {
|
||||
flex: 1,
|
||||
@@ -980,10 +1010,10 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bottomBarLabel: {
|
||||
...typography.small,
|
||||
color: colors.textSecondary,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
bottomBarLabelDisabled: {
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
},
|
||||
|
||||
// Bottom sheet (More menu / Move folder picker)
|
||||
@@ -997,23 +1027,23 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: colors.popover,
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetTall: {
|
||||
maxHeight: '75%',
|
||||
sheetHandleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
sheetHandle: {
|
||||
alignSelf: 'center',
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -1022,11 +1052,11 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
@@ -1045,7 +1075,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 48,
|
||||
},
|
||||
moreItemPressed: { backgroundColor: colors.surfaceHover },
|
||||
moreItemPressed: { backgroundColor: c.surfaceHover },
|
||||
moreItemIcon: {
|
||||
width: 20,
|
||||
alignItems: 'center',
|
||||
@@ -1053,26 +1083,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
moreItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Move folder row
|
||||
moveRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
moveRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
moveRowLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
moveRowLabelDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+104
-35
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -7,10 +7,42 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { FileText, Folder, HardDrive } from 'lucide-react-native';
|
||||
import {
|
||||
FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio,
|
||||
FileArchive, FileSpreadsheet, FileCode2,
|
||||
} from 'lucide-react-native';
|
||||
import { getFileNodes } from '../api/files';
|
||||
import type { FileNode } from '../api/types';
|
||||
import { colors, spacing, typography } from '../theme/tokens';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
|
||||
function pickFileIcon(name: string) {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage;
|
||||
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return FileVideo;
|
||||
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return FileAudio;
|
||||
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return FileArchive;
|
||||
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return FileSpreadsheet;
|
||||
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return FileCode2;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
function fileIconColor(name: string, c: ThemePalette): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return c.calendar.green;
|
||||
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return c.calendar.purple;
|
||||
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return c.calendar.pink;
|
||||
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return c.calendar.orange;
|
||||
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return c.calendar.teal;
|
||||
if (['pdf'].includes(ext)) return c.error;
|
||||
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return c.calendar.indigo;
|
||||
return c.textMuted;
|
||||
}
|
||||
|
||||
function isHidden(node: FileNode): boolean {
|
||||
return node.name.startsWith('.');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (bytes == null) return '';
|
||||
@@ -20,10 +52,18 @@ function formatFileSize(bytes?: number): string {
|
||||
}
|
||||
|
||||
export default function FilesScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [files, setFiles] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const showIcons = useSettingsStore((s) => s.filesShowIcons);
|
||||
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
|
||||
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
|
||||
const sortKey = useSettingsStore((s) => s.filesDefaultSortKey);
|
||||
const sortDir = useSettingsStore((s) => s.filesDefaultSortDir);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -37,6 +77,31 @@ export default function FilesScreen() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const visibleFiles = useMemo(() => {
|
||||
let out = files;
|
||||
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
|
||||
// Directories always sort to the top regardless of sort key, then secondary
|
||||
// sort by the requested column. This matches macOS Finder / web file pickers.
|
||||
const dir = sortDir === 'desc' ? -1 : 1;
|
||||
return [...out].sort((a, b) => {
|
||||
const aDir = a.type === 'directory' ? 0 : 1;
|
||||
const bDir = b.type === 'directory' ? 0 : 1;
|
||||
if (aDir !== bDir) return aDir - bDir;
|
||||
switch (sortKey) {
|
||||
case 'size':
|
||||
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
|
||||
case 'modified': {
|
||||
const am = a.updated ? new Date(a.updated).getTime() : 0;
|
||||
const bm = b.updated ? new Date(b.updated).getTime() : 0;
|
||||
return (am - bm) * dir;
|
||||
}
|
||||
case 'name':
|
||||
default:
|
||||
return a.name.localeCompare(b.name) * dir;
|
||||
}
|
||||
});
|
||||
}, [files, showHiddenFiles, sortKey, sortDir]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
@@ -44,7 +109,7 @@ export default function FilesScreen() {
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -60,14 +125,14 @@ export default function FilesScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
if (visibleFiles.length === 0) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Files</Text>
|
||||
</View>
|
||||
<View style={styles.center}>
|
||||
<HardDrive size={48} color={colors.textMuted} />
|
||||
<HardDrive size={48} color={c.textMuted} />
|
||||
<Text style={styles.emptyText}>No files yet</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -80,46 +145,49 @@ export default function FilesScreen() {
|
||||
<Text style={styles.title}>Files</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={files}
|
||||
data={visibleFiles}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.fileRow}>
|
||||
{item.type === 'directory' ? (
|
||||
<Folder size={20} color={colors.primary} />
|
||||
) : (
|
||||
<FileText size={20} color={colors.textMuted} />
|
||||
)}
|
||||
<View style={styles.fileInfo}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.size != null && (
|
||||
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
|
||||
)}
|
||||
renderItem={({ item }) => {
|
||||
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
|
||||
const tint = item.type === 'directory'
|
||||
? (coloredIcons ? c.primary : c.textMuted)
|
||||
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
|
||||
return (
|
||||
<View style={styles.fileRow}>
|
||||
{showIcons && <Icon size={20} color={tint} />}
|
||||
<View style={styles.fileInfo}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.size != null && (
|
||||
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.h2.fontSize,
|
||||
fontWeight: '700' as const,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
@@ -128,12 +196,12 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
fontSize: typography.body.fontSize,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.error,
|
||||
color: c.error,
|
||||
fontSize: typography.body.fontSize,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
@@ -142,11 +210,11 @@ const styles = StyleSheet.create({
|
||||
marginTop: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.primary,
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryText: {
|
||||
color: colors.primaryForeground,
|
||||
color: c.primaryForeground,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
fileRow: {
|
||||
@@ -155,7 +223,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
fileInfo: {
|
||||
@@ -163,11 +231,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
fileName: {
|
||||
fontSize: typography.body.fontSize,
|
||||
color: colors.text,
|
||||
color: c.text,
|
||||
},
|
||||
fileSize: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: colors.textMuted,
|
||||
color: c.textMuted,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,12 +22,15 @@ import type { EmailAddress } from '../api/types';
|
||||
import ContactListRow from '../components/contacts/ContactListRow';
|
||||
import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
|
||||
import Dialog from '../components/Dialog';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'GroupDetail'>;
|
||||
type Route = RouteProp<RootStackParamList, 'GroupDetail'>;
|
||||
|
||||
export default function GroupDetailScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const route = useRoute<Route>();
|
||||
const { groupId } = route.params;
|
||||
@@ -53,7 +56,7 @@ export default function GroupDetailScreen() {
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={colors.text} />
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>Group</Text>
|
||||
</View>
|
||||
@@ -127,7 +130,7 @@ export default function GroupDetailScreen() {
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={colors.text} />
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -136,17 +139,17 @@ export default function GroupDetailScreen() {
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Edit2 size={18} color={colors.text} />
|
||||
<Edit2 size={18} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setConfirmDelete(true)} style={styles.headerBtn} hitSlop={8}>
|
||||
<Trash2 size={18} color={colors.error} />
|
||||
<Trash2 size={18} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.heroIcon}>
|
||||
<Users size={32} color={colors.primary} />
|
||||
<Users size={32} color={c.primary} />
|
||||
</View>
|
||||
<Text style={styles.heroName}>{name}</Text>
|
||||
<Text style={styles.heroSubtitle}>
|
||||
@@ -156,11 +159,11 @@ export default function GroupDetailScreen() {
|
||||
|
||||
<View style={styles.actionsRow}>
|
||||
<Pressable onPress={emailAll} style={styles.actionBtn}>
|
||||
<Mail size={16} color={colors.primary} />
|
||||
<Mail size={16} color={c.primary} />
|
||||
<Text style={styles.actionLabel}>Email all</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setPickerOpen(true)} style={[styles.actionBtn, styles.actionBtnPrimary]}>
|
||||
<Plus size={16} color={colors.primaryForeground} />
|
||||
<Plus size={16} color={c.primaryForeground} />
|
||||
<Text style={[styles.actionLabel, styles.actionLabelPrimary]}>Add member</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -181,14 +184,14 @@ export default function GroupDetailScreen() {
|
||||
style={styles.removeMemberBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<UserMinus size={16} color={colors.error} />
|
||||
<UserMinus size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.empty}>
|
||||
<Users size={40} color={colors.surfaceActive} />
|
||||
<Users size={40} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No members yet</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap "Add member" to get started</Text>
|
||||
</View>
|
||||
@@ -231,8 +234,9 @@ export default function GroupDetailScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -240,14 +244,14 @@ 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 },
|
||||
headerActions: { flexDirection: 'row', gap: spacing.xs },
|
||||
|
||||
hero: {
|
||||
@@ -258,12 +262,12 @@ const styles = StyleSheet.create({
|
||||
heroIcon: {
|
||||
width: 80, height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: colors.primaryBg,
|
||||
backgroundColor: c.primaryBg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroName: { ...typography.h2, color: colors.text, marginTop: spacing.sm },
|
||||
heroSubtitle: { ...typography.body, color: colors.textSecondary },
|
||||
heroName: { ...typography.h2, color: c.text, marginTop: spacing.sm },
|
||||
heroSubtitle: { ...typography.body, color: c.textSecondary },
|
||||
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -280,11 +284,11 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
actionBtnPrimary: { backgroundColor: colors.primary },
|
||||
actionLabel: { ...typography.bodyMedium, color: colors.text },
|
||||
actionLabelPrimary: { color: colors.primaryForeground },
|
||||
actionBtnPrimary: { backgroundColor: c.primary },
|
||||
actionLabel: { ...typography.bodyMedium, color: c.text },
|
||||
actionLabelPrimary: { color: c.primaryForeground },
|
||||
|
||||
memberRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -296,12 +300,13 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.full,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||
|
||||
empty: { alignItems: 'center', paddingVertical: spacing.xxxl, gap: spacing.sm },
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
missingText: { ...typography.body, color: colors.textMuted },
|
||||
});
|
||||
missingText: { ...typography.body, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
|
||||
+19
-14
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Eye, EyeOff, X } from 'lucide-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';
|
||||
import { Button, Input } from '../components';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
@@ -13,6 +14,8 @@ interface LoginScreenProps {
|
||||
}
|
||||
|
||||
export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
@@ -56,7 +59,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
<SafeAreaView style={styles.container}>
|
||||
{isAddMode && onCancel ? (
|
||||
<Pressable onPress={onCancel} style={styles.cancelButton} hitSlop={10}>
|
||||
<X size={24} color={colors.text} />
|
||||
<X size={24} color={c.text} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<KeyboardAvoidingView
|
||||
@@ -113,9 +116,9 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
rightIcon={
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? (
|
||||
<EyeOff size={20} color={colors.textMuted} />
|
||||
<EyeOff size={20} color={c.textMuted} />
|
||||
) : (
|
||||
<Eye size={20} color={colors.textMuted} />
|
||||
<Eye size={20} color={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
}
|
||||
@@ -162,8 +165,9 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
cancelButton: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
@@ -189,8 +193,8 @@ const styles = StyleSheet.create({
|
||||
width: 72,
|
||||
height: 72,
|
||||
},
|
||||
appName: { ...typography.h1, color: colors.text },
|
||||
tagline: { ...typography.caption, color: colors.textMuted, marginTop: spacing.xs },
|
||||
appName: { ...typography.h1, color: c.text },
|
||||
tagline: { ...typography.caption, color: c.textMuted, marginTop: spacing.xs },
|
||||
|
||||
form: { gap: spacing.lg },
|
||||
|
||||
@@ -199,7 +203,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
errorText: {
|
||||
...typography.caption,
|
||||
color: colors.error,
|
||||
color: c.error,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
@@ -209,12 +213,13 @@ const styles = StyleSheet.create({
|
||||
marginVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
dividerLine: { flex: 1, height: 1, backgroundColor: colors.border },
|
||||
dividerText: { ...typography.caption, color: colors.textMuted },
|
||||
dividerLine: { flex: 1, height: 1, backgroundColor: c.border },
|
||||
dividerText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
oauthRow: { gap: spacing.md },
|
||||
|
||||
footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs },
|
||||
footerText: { ...typography.caption, color: colors.textMuted },
|
||||
footerLink: { ...typography.caption, color: colors.textLink },
|
||||
});
|
||||
footerText: { ...typography.caption, color: c.textMuted },
|
||||
footerLink: { ...typography.caption, color: c.textLink },
|
||||
});
|
||||
}
|
||||
|
||||
+103
-150
@@ -9,7 +9,8 @@ import {
|
||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
|
||||
type LucideIcon,
|
||||
} from 'lucide-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';
|
||||
import { ReadingSettings } from '../components/settings/ReadingSettings';
|
||||
import { NotificationSettings } from '../components/settings/NotificationSettings';
|
||||
import { AccountSettings } from '../components/settings/AccountSettings';
|
||||
@@ -34,6 +35,7 @@ import { ContentSendersSettings } from '../components/settings/ContentSendersSet
|
||||
import { LanguageSettings } from '../components/settings/LanguageSettings';
|
||||
import { ComposingSettings } from '../components/settings/ComposingSettings';
|
||||
import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
@@ -147,8 +149,23 @@ interface SettingsScreenProps {
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
|
||||
const groupedTabs = groupTabs();
|
||||
// Subscribe to locale so labels re-render when the user picks a different language.
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const groupedTabs = React.useMemo(() => {
|
||||
void locale; // dependency: re-translate on locale change
|
||||
return groupTabs().map((g) => ({
|
||||
...g,
|
||||
label: t(`settings.tab_groups.${g.group}`, g.label),
|
||||
items: g.items.map((tab) => ({
|
||||
...tab,
|
||||
label: t(`settings.tabs.${tab.id}`, tab.label),
|
||||
})),
|
||||
}));
|
||||
}, [locale, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTab) return;
|
||||
@@ -166,9 +183,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
};
|
||||
|
||||
if (selectedTab) {
|
||||
const tabDef = TABS.find(t => t.id === selectedTab)!;
|
||||
const tabDef = TABS.find((tab) => tab.id === selectedTab)!;
|
||||
const Component = TAB_COMPONENTS[selectedTab];
|
||||
const TabIcon = tabDef.icon;
|
||||
const tabLabel = t(`settings.tabs.${tabDef.id}`, tabDef.label);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
@@ -177,10 +195,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
onPress={() => setSelectedTab(null)}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<TabIcon size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabDef.label}</Text>
|
||||
<TabIcon size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabLabel}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
@@ -198,10 +216,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Settings size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
<Settings size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
@@ -230,7 +248,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabItemLeft}>
|
||||
<Icon size={16} color={colors.mutedForeground} />
|
||||
<Icon size={16} color={c.mutedForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.tabItemLabel,
|
||||
@@ -250,7 +268,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.mutedForeground} />
|
||||
<ChevronRight size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -265,8 +283,8 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
onPress={onLogout}
|
||||
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
|
||||
>
|
||||
<LogOut size={16} color={colors.error} />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
<LogOut size={16} color={c.error} />
|
||||
<Text style={styles.logoutText}>{t('sidebar.sign_out', 'Sign Out')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -274,147 +292,82 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBackBtn: {
|
||||
width: 40, height: 40,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerBackBtnPressed: { backgroundColor: c.accent },
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
|
||||
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBackBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerBackBtnPressed: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.h3,
|
||||
color: colors.text,
|
||||
},
|
||||
scrollArea: { flex: 1 },
|
||||
scrollContent: { paddingBottom: 40 },
|
||||
detailContent: { padding: spacing.lg, paddingBottom: 40 },
|
||||
|
||||
scrollArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
detailContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
tabList: { paddingVertical: spacing.sm },
|
||||
|
||||
tabList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
groupDivider: {
|
||||
height: 1, backgroundColor: c.border,
|
||||
marginHorizontal: 20, marginVertical: spacing.sm,
|
||||
},
|
||||
groupHeader: { paddingHorizontal: 20, paddingTop: spacing.md, paddingBottom: 6 },
|
||||
groupLabel: {
|
||||
fontSize: 11, fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
|
||||
// Group divider - webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20, paddingVertical: 14,
|
||||
},
|
||||
tabItemPressed: { backgroundColor: c.muted },
|
||||
tabItemLeft: {
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
gap: spacing.md, flex: 1,
|
||||
},
|
||||
tabItemLabel: { ...typography.body, color: c.text },
|
||||
|
||||
// Group header - webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 6, paddingVertical: 2,
|
||||
},
|
||||
experimentalText: { fontSize: 10, fontWeight: '500', color: c.warning },
|
||||
|
||||
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
tabItemPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
tabItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
tabItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
tabItemDisabled: { opacity: 0.55 },
|
||||
tabItemLabelDisabled: { color: c.mutedForeground },
|
||||
notWorkingBadge: {
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8, paddingVertical: 2,
|
||||
},
|
||||
notWorkingText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||
|
||||
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
experimentalText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.warning,
|
||||
},
|
||||
|
||||
tabItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
tabItemLabelDisabled: {
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notWorkingBadge: {
|
||||
backgroundColor: colors.muted,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
notWorkingText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
logoutBtnPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
logoutText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
},
|
||||
});
|
||||
logoutSection: {
|
||||
borderTopWidth: 1, borderTopColor: c.border,
|
||||
paddingHorizontal: 20, paddingVertical: spacing.md,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: spacing.md,
|
||||
paddingVertical: 10, paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
logoutBtnPressed: { backgroundColor: c.muted },
|
||||
logoutText: { ...typography.body, color: c.error },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,10 +7,19 @@ vi.mock('../../api/email', () => ({
|
||||
getFullEmail: vi.fn(),
|
||||
setEmailKeywords: vi.fn(),
|
||||
moveEmail: vi.fn(),
|
||||
archiveEmails: vi.fn(),
|
||||
restoreEmailMailboxes: vi.fn(),
|
||||
deleteEmail: vi.fn(),
|
||||
searchEmails: vi.fn(),
|
||||
}));
|
||||
|
||||
// settings-store transitively pulls in jmap-client / expo-secure-store, which
|
||||
// trip on react-native's Flow-typed entrypoint under vitest. The store only
|
||||
// reads archiveMode in archiveEmail, so a minimal stub is enough.
|
||||
vi.mock('../settings-store', () => ({
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
|
||||
}));
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import { useEmailStore } from '../email-store';
|
||||
|
||||
|
||||
+172
-4
@@ -9,10 +9,13 @@ import {
|
||||
getFullEmail,
|
||||
setEmailKeywords,
|
||||
moveEmail,
|
||||
archiveEmails as apiArchiveEmails,
|
||||
deleteEmail as apiDeleteEmail,
|
||||
restoreEmailMailboxes,
|
||||
searchEmails as apiSearchEmails,
|
||||
} from '../api/email';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
import { useSettingsStore } from './settings-store';
|
||||
|
||||
export interface EmailFilters {
|
||||
from?: string;
|
||||
@@ -25,6 +28,19 @@ export interface EmailFilters {
|
||||
isUnread?: boolean;
|
||||
}
|
||||
|
||||
// Snapshot of an action that can still be reversed via the undo snackbar.
|
||||
// We store the full email object so undo can re-insert it into the visible list
|
||||
// optimistically without waiting for a refetch.
|
||||
export interface UndoEntry {
|
||||
kind: 'archive' | 'delete' | 'move' | 'spam';
|
||||
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
|
||||
label: string;
|
||||
/** Time the entry was created - the snackbar uses this to drive its timer. */
|
||||
createdAt: number;
|
||||
/** Each item is one email's pre-action mailboxIds, used to restore it. */
|
||||
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
|
||||
}
|
||||
|
||||
export interface EmailState {
|
||||
mailboxes: Mailbox[];
|
||||
currentMailboxId: string | null;
|
||||
@@ -34,6 +50,7 @@ export interface EmailState {
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
filters: EmailFilters;
|
||||
pendingUndo: UndoEntry | null;
|
||||
|
||||
fetchMailboxes: () => Promise<void>;
|
||||
selectMailbox: (mailboxId: string) => Promise<void>;
|
||||
@@ -43,8 +60,12 @@ export interface EmailState {
|
||||
markRead: (emailId: string) => Promise<void>;
|
||||
markUnread: (emailId: string) => Promise<void>;
|
||||
toggleStar: (emailId: string, starred: boolean) => Promise<void>;
|
||||
togglePin: (emailId: string, pinned: boolean) => Promise<void>;
|
||||
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise<void>;
|
||||
archiveEmail: (emailId: string) => Promise<void>;
|
||||
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
|
||||
undoLast: () => Promise<void>;
|
||||
clearUndo: () => void;
|
||||
searchEmails: (query: string) => Promise<Email[]>;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setFilters: (filters: EmailFilters) => void;
|
||||
@@ -102,6 +123,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
|
||||
fetchMailboxes: async () => {
|
||||
try {
|
||||
@@ -115,6 +137,8 @@ export const useEmailStore = create<EmailState>()(
|
||||
|
||||
selectMailbox: async (mailboxId) => {
|
||||
// Reset search/filters when switching mailbox - matches webmail behavior.
|
||||
// Switching mailboxes invalidates any pending undo: the snackbar would be
|
||||
// stale and the restored email would re-appear in a different view.
|
||||
set({
|
||||
currentMailboxId: mailboxId,
|
||||
loading: true,
|
||||
@@ -123,10 +147,12 @@ export const useEmailStore = create<EmailState>()(
|
||||
totalEmails: 0,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
});
|
||||
try {
|
||||
const filter = buildJmapFilter(mailboxId, '', {});
|
||||
const { ids, total } = await queryEmails(mailboxId, { limit: 50, filter });
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
|
||||
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
} catch (err) {
|
||||
@@ -142,9 +168,10 @@ export const useEmailStore = create<EmailState>()(
|
||||
set({ loading: true });
|
||||
try {
|
||||
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const { ids } = await queryEmails(currentMailboxId, {
|
||||
position: emails.length,
|
||||
limit: 50,
|
||||
limit,
|
||||
filter,
|
||||
});
|
||||
const newEmails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
@@ -162,7 +189,8 @@ export const useEmailStore = create<EmailState>()(
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
|
||||
const { ids, total } = await queryEmails(currentMailboxId, { limit: 50, filter });
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const { ids, total } = await queryEmails(currentMailboxId, { limit, filter });
|
||||
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
} catch (err) {
|
||||
@@ -232,16 +260,156 @@ export const useEmailStore = create<EmailState>()(
|
||||
});
|
||||
},
|
||||
|
||||
togglePin: async (emailId, pinned) => {
|
||||
const email = get().emails.find((e) => e.id === emailId);
|
||||
if (!email) return;
|
||||
const keywords = { ...email.keywords };
|
||||
if (pinned) {
|
||||
keywords.$important = true;
|
||||
} else {
|
||||
delete keywords.$important;
|
||||
}
|
||||
await setEmailKeywords(emailId, keywords);
|
||||
set({
|
||||
emails: get().emails.map((e) =>
|
||||
e.id === emailId ? { ...e, keywords } : e,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
|
||||
const email = get().emails.find((e) => e.id === emailId);
|
||||
const original = email ? { ...email.mailboxIds } : null;
|
||||
|
||||
await moveEmail(emailId, fromMailboxId, toMailboxId);
|
||||
set({ emails: get().emails.filter((e) => e.id !== emailId) });
|
||||
|
||||
if (email && original) {
|
||||
const targetName = get().mailboxes.find((m) => m.id === toMailboxId)?.name;
|
||||
set({
|
||||
pendingUndo: {
|
||||
kind: 'move',
|
||||
label: targetName ? `Email moved to ${targetName}` : 'Email moved',
|
||||
createdAt: Date.now(),
|
||||
items: [{ email, originalMailboxIds: original }],
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
archiveEmail: async (emailId) => {
|
||||
const { emails, mailboxes } = get();
|
||||
const email = emails.find((e) => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const archiveMailbox = mailboxes.find(
|
||||
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
|
||||
);
|
||||
if (!archiveMailbox) return;
|
||||
if (email.mailboxIds?.[archiveMailbox.id]) return;
|
||||
|
||||
const mode = useSettingsStore.getState().archiveMode;
|
||||
const original = { ...email.mailboxIds };
|
||||
|
||||
await apiArchiveEmails(
|
||||
[{ id: email.id, receivedAt: email.receivedAt }],
|
||||
archiveMailbox.id,
|
||||
mode,
|
||||
mailboxes,
|
||||
);
|
||||
|
||||
set({
|
||||
emails: get().emails.filter((e) => e.id !== emailId),
|
||||
pendingUndo: {
|
||||
kind: 'archive',
|
||||
label: 'Email archived',
|
||||
createdAt: Date.now(),
|
||||
items: [{ email, originalMailboxIds: original }],
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-sort modes may have created new year/month folders - refresh the
|
||||
// mailbox list so the sidebar picks them up on the next render.
|
||||
if (mode !== 'single') {
|
||||
void get().fetchMailboxes();
|
||||
}
|
||||
},
|
||||
|
||||
deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => {
|
||||
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
|
||||
const email = get().emails.find((e) => e.id === emailId);
|
||||
const original = email ? { ...email.mailboxIds } : null;
|
||||
const settings = useSettingsStore.getState();
|
||||
const { mailboxes } = get();
|
||||
const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
|
||||
const inJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
|
||||
const inTrash = currentMailboxId === trashMailboxId;
|
||||
|
||||
// Resolve effective destination:
|
||||
// - already in trash → must destroy (no further folder to move to)
|
||||
// - in junk and the user opted to skip the trash for junk → destroy
|
||||
// - the user set 'permanent' as the global default → destroy
|
||||
// - otherwise → move to trash and offer undo
|
||||
const destroy =
|
||||
inTrash ||
|
||||
settings.deleteAction === 'permanent' ||
|
||||
(settings.permanentlyDeleteJunk && inJunk);
|
||||
|
||||
if (destroy) {
|
||||
// Use the trash mailbox as the "current" so apiDeleteEmail takes the
|
||||
// destroy branch even when the source folder isn't trash.
|
||||
await apiDeleteEmail(emailId, trashMailboxId, trashMailboxId);
|
||||
} else {
|
||||
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
|
||||
}
|
||||
set({ emails: get().emails.filter((e) => e.id !== emailId) });
|
||||
|
||||
// Permanent destroy can't be undone - skip the snackbar so we don't
|
||||
// promise an undo we can't deliver.
|
||||
if (email && original && !destroy) {
|
||||
set({
|
||||
pendingUndo: {
|
||||
kind: 'delete',
|
||||
label: 'Email moved to Trash',
|
||||
createdAt: Date.now(),
|
||||
items: [{ email, originalMailboxIds: original }],
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
undoLast: async () => {
|
||||
const entry = get().pendingUndo;
|
||||
if (!entry) return;
|
||||
set({ pendingUndo: null });
|
||||
|
||||
try {
|
||||
await restoreEmailMailboxes(
|
||||
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
|
||||
);
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : 'Undo failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-insert each restored email into the visible list if its original
|
||||
// mailboxIds include the current view. Server is the source of truth for
|
||||
// ordering, but local re-insertion gives the user instant feedback.
|
||||
const { currentMailboxId, emails } = get();
|
||||
if (currentMailboxId) {
|
||||
const restored = entry.items
|
||||
.filter((it) => it.originalMailboxIds[currentMailboxId])
|
||||
.map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds }));
|
||||
if (restored.length > 0) {
|
||||
const merged = [...restored, ...emails].sort(
|
||||
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(),
|
||||
);
|
||||
set({ emails: merged });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearUndo: () => set({ pendingUndo: null }),
|
||||
|
||||
searchEmails: async (query) => {
|
||||
const ids = await apiSearchEmails(query);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
|
||||
|
||||
interface NetworkState {
|
||||
/** Best-effort online flag. True when we have an internet-reachable connection
|
||||
* (or when NetInfo hasn't yet reported anything — assume online by default
|
||||
* so we don't flash the offline banner during cold start). */
|
||||
online: boolean;
|
||||
/** Lower-level connection flag. True if a network interface is up, regardless
|
||||
* of whether the internet is actually reachable. */
|
||||
connected: boolean;
|
||||
|
||||
init: () => () => void;
|
||||
}
|
||||
|
||||
function fromNetInfo(state: NetInfoState): { online: boolean; connected: boolean } {
|
||||
const connected = state.isConnected === true;
|
||||
// isInternetReachable is `null` until tested; treat null as "we don't know"
|
||||
// and fall back to connected.
|
||||
const online = state.isInternetReachable === false ? false : connected;
|
||||
return { online, connected };
|
||||
}
|
||||
|
||||
export const useNetworkStore = create<NetworkState>((set) => ({
|
||||
online: true,
|
||||
connected: true,
|
||||
|
||||
init: () => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
set(fromNetInfo(state));
|
||||
});
|
||||
void NetInfo.fetch().then((state) => set(fromNetInfo(state)));
|
||||
return unsubscribe;
|
||||
},
|
||||
}));
|
||||
+244
-36
@@ -7,16 +7,53 @@ export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
export type DeleteAction = 'trash' | 'permanent';
|
||||
export type MailLayout = 'split' | 'focus';
|
||||
export type MailAttachmentAction = 'preview' | 'download';
|
||||
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||
export type SwipeAction =
|
||||
| 'none'
|
||||
| 'archive'
|
||||
| 'delete'
|
||||
| 'spam'
|
||||
| 'read'
|
||||
| 'star'
|
||||
| 'pin'
|
||||
| 'move';
|
||||
export type SwipeMode = 'instant' | 'reveal';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
|
||||
export type FirstDayOfWeek = 0 | 1;
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type CalendarHoverPreview = 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s' | 'off';
|
||||
export type FilesFolderLayout = 'inline' | 'sidebar';
|
||||
export type FilesViewMode = 'list' | 'grid';
|
||||
export type FilesSortKey = 'name' | 'size' | 'modified';
|
||||
export type FilesSortDir = 'asc' | 'desc';
|
||||
export type NotificationSound = 'default' | 'chime' | 'ping' | 'pop' | 'none';
|
||||
|
||||
const STORAGE_KEY = 'webmail:settings:v1';
|
||||
|
||||
export type SwipeAction = 'none' | 'archive' | 'delete' | 'spam' | 'read' | 'star';
|
||||
export interface SidebarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
showOnMobile: boolean;
|
||||
}
|
||||
|
||||
interface PersistedSettings {
|
||||
// Privacy & content
|
||||
externalContentPolicy: ExternalContentPolicy;
|
||||
trustedSenders: string[];
|
||||
trustedSendersAddressBook: boolean;
|
||||
senderFavicons: boolean;
|
||||
hideInlineImageAttachments: boolean;
|
||||
|
||||
// Contacts
|
||||
groupContactsByLetter: boolean;
|
||||
|
||||
// Appearance
|
||||
theme: ThemeMode;
|
||||
fontSize: FontSize;
|
||||
@@ -24,31 +61,152 @@ interface PersistedSettings {
|
||||
showToolbarLabels: boolean;
|
||||
animationsEnabled: boolean;
|
||||
emailAlwaysLightMode: boolean;
|
||||
activeThemeId: string | null;
|
||||
|
||||
// Composing
|
||||
autoSelectReplyIdentity: boolean;
|
||||
attachmentReminderEnabled: boolean;
|
||||
attachmentReminderKeywords: string[];
|
||||
plainTextMode: boolean;
|
||||
|
||||
// Reading
|
||||
markAsReadDelay: number;
|
||||
deleteAction: DeleteAction;
|
||||
permanentlyDeleteJunk: boolean;
|
||||
showPreview: boolean;
|
||||
mailLayout: MailLayout;
|
||||
emailsPerPage: number;
|
||||
disableThreading: boolean;
|
||||
mailAttachmentAction: MailAttachmentAction;
|
||||
attachmentPosition: AttachmentPosition;
|
||||
|
||||
// Layout / list interactions
|
||||
swipeLeftAction: SwipeAction;
|
||||
swipeRightAction: SwipeAction;
|
||||
swipeMode: SwipeMode;
|
||||
|
||||
// Archive
|
||||
archiveMode: ArchiveMode;
|
||||
|
||||
// Calendar
|
||||
calendarDefaultView: CalendarView;
|
||||
calendarFirstDayOfWeek: FirstDayOfWeek;
|
||||
calendarTimeFormat: TimeFormat;
|
||||
calendarShowTimeInMonth: boolean;
|
||||
calendarShowWeekNumbers: boolean;
|
||||
calendarHoverPreview: CalendarHoverPreview;
|
||||
showBirthdayCalendar: boolean;
|
||||
enableCalendarTasks: boolean;
|
||||
showTasksOnCalendar: boolean;
|
||||
|
||||
// Files
|
||||
filesFolderLayout: FilesFolderLayout;
|
||||
filesDefaultViewMode: FilesViewMode;
|
||||
filesDefaultSortKey: FilesSortKey;
|
||||
filesDefaultSortDir: FilesSortDir;
|
||||
filesShowIcons: boolean;
|
||||
filesColoredIcons: boolean;
|
||||
filesShowThumbnails: boolean;
|
||||
filesShowHiddenFiles: boolean;
|
||||
|
||||
// Notifications
|
||||
notificationSoundChoice: NotificationSound;
|
||||
emailNotificationsEnabled: boolean;
|
||||
emailNotificationSound: boolean;
|
||||
calendarNotificationsEnabled: boolean;
|
||||
calendarNotificationSound: boolean;
|
||||
calendarInvitationParsingEnabled: boolean;
|
||||
|
||||
// Sidebar apps
|
||||
sidebarApps: SidebarApp[];
|
||||
keepAppsLoaded: boolean;
|
||||
|
||||
// S/MIME defaults (key/cert lists are server-managed; only UI-level prefs persist)
|
||||
smimeDefaultEncrypt: boolean;
|
||||
smimeRememberUnlocked: boolean;
|
||||
smimeAutoImport: boolean;
|
||||
|
||||
// Filters UI state
|
||||
filtersExpandedView: boolean;
|
||||
|
||||
// Plugins (UI-level enable map)
|
||||
pluginEnabled: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
externalContentPolicy: 'ask',
|
||||
trustedSenders: [],
|
||||
trustedSendersAddressBook: false,
|
||||
senderFavicons: true,
|
||||
hideInlineImageAttachments: true,
|
||||
|
||||
groupContactsByLetter: true,
|
||||
|
||||
theme: 'system',
|
||||
fontSize: 'medium',
|
||||
density: 'regular',
|
||||
showToolbarLabels: true,
|
||||
animationsEnabled: true,
|
||||
emailAlwaysLightMode: false,
|
||||
activeThemeId: null,
|
||||
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: true,
|
||||
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
|
||||
plainTextMode: false,
|
||||
|
||||
markAsReadDelay: 0,
|
||||
deleteAction: 'trash',
|
||||
permanentlyDeleteJunk: false,
|
||||
showPreview: true,
|
||||
mailLayout: 'split',
|
||||
emailsPerPage: 25,
|
||||
disableThreading: false,
|
||||
mailAttachmentAction: 'preview',
|
||||
attachmentPosition: 'beside-sender',
|
||||
|
||||
swipeLeftAction: 'archive',
|
||||
swipeRightAction: 'read',
|
||||
swipeMode: 'instant',
|
||||
|
||||
archiveMode: 'single',
|
||||
|
||||
calendarDefaultView: 'month',
|
||||
calendarFirstDayOfWeek: 1,
|
||||
calendarTimeFormat: '24h',
|
||||
calendarShowTimeInMonth: true,
|
||||
calendarShowWeekNumbers: false,
|
||||
calendarHoverPreview: 'delay-500ms',
|
||||
showBirthdayCalendar: true,
|
||||
enableCalendarTasks: false,
|
||||
showTasksOnCalendar: true,
|
||||
|
||||
filesFolderLayout: 'inline',
|
||||
filesDefaultViewMode: 'list',
|
||||
filesDefaultSortKey: 'name',
|
||||
filesDefaultSortDir: 'asc',
|
||||
filesShowIcons: true,
|
||||
filesColoredIcons: true,
|
||||
filesShowThumbnails: true,
|
||||
filesShowHiddenFiles: false,
|
||||
|
||||
notificationSoundChoice: 'default',
|
||||
emailNotificationsEnabled: true,
|
||||
emailNotificationSound: true,
|
||||
calendarNotificationsEnabled: true,
|
||||
calendarNotificationSound: true,
|
||||
calendarInvitationParsingEnabled: true,
|
||||
|
||||
sidebarApps: [],
|
||||
keepAppsLoaded: false,
|
||||
|
||||
smimeDefaultEncrypt: false,
|
||||
smimeRememberUnlocked: false,
|
||||
smimeAutoImport: true,
|
||||
|
||||
filtersExpandedView: false,
|
||||
|
||||
pluginEnabled: {},
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
@@ -59,6 +217,14 @@ export interface SettingsState extends PersistedSettings {
|
||||
|
||||
fetchIdentities: () => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
|
||||
// Generic setter — preferred for new code.
|
||||
updateSetting: <K extends keyof PersistedSettings>(
|
||||
key: K,
|
||||
value: PersistedSettings[K],
|
||||
) => void;
|
||||
|
||||
// Legacy named setters — preserved so existing call sites keep working.
|
||||
setExternalContentPolicy: (policy: ExternalContentPolicy) => void;
|
||||
setSenderFavicons: (enabled: boolean) => void;
|
||||
setGroupContactsByLetter: (enabled: boolean) => void;
|
||||
@@ -73,30 +239,36 @@ export interface SettingsState extends PersistedSettings {
|
||||
setAttachmentReminderKeywords: (keywords: string[]) => void;
|
||||
setSwipeLeftAction: (action: SwipeAction) => void;
|
||||
setSwipeRightAction: (action: SwipeAction) => void;
|
||||
setSwipeMode: (mode: SwipeMode) => void;
|
||||
setArchiveMode: (mode: ArchiveMode) => void;
|
||||
|
||||
// Trusted senders
|
||||
addTrustedSender: (email: string) => void;
|
||||
removeTrustedSender: (email: string) => void;
|
||||
isSenderTrusted: (email: string) => boolean;
|
||||
|
||||
// Sidebar apps
|
||||
addSidebarApp: (app: Omit<SidebarApp, 'id'>) => void;
|
||||
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
|
||||
removeSidebarApp: (id: string) => void;
|
||||
reorderSidebarApps: (apps: SidebarApp[]) => void;
|
||||
|
||||
// Plugins
|
||||
setPluginEnabled: (id: string, enabled: boolean) => void;
|
||||
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const PERSIST_KEYS: (keyof PersistedSettings)[] = Object.keys(
|
||||
DEFAULT_PERSISTED,
|
||||
) as (keyof PersistedSettings)[];
|
||||
|
||||
function snapshot(state: SettingsState): PersistedSettings {
|
||||
return {
|
||||
externalContentPolicy: state.externalContentPolicy,
|
||||
trustedSenders: state.trustedSenders,
|
||||
senderFavicons: state.senderFavicons,
|
||||
groupContactsByLetter: state.groupContactsByLetter,
|
||||
theme: state.theme,
|
||||
fontSize: state.fontSize,
|
||||
density: state.density,
|
||||
showToolbarLabels: state.showToolbarLabels,
|
||||
animationsEnabled: state.animationsEnabled,
|
||||
emailAlwaysLightMode: state.emailAlwaysLightMode,
|
||||
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||
swipeLeftAction: state.swipeLeftAction,
|
||||
swipeRightAction: state.swipeRightAction,
|
||||
};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of PERSIST_KEYS) {
|
||||
out[k] = state[k];
|
||||
}
|
||||
return out as unknown as PersistedSettings;
|
||||
}
|
||||
|
||||
function persist(state: PersistedSettings): void {
|
||||
@@ -105,6 +277,24 @@ function persist(state: PersistedSettings): void {
|
||||
});
|
||||
}
|
||||
|
||||
function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSettings {
|
||||
const out: Record<string, unknown> = { ...DEFAULT_PERSISTED };
|
||||
for (const k of PERSIST_KEYS) {
|
||||
const v = parsed[k];
|
||||
if (v === undefined || v === null) continue;
|
||||
const def = DEFAULT_PERSISTED[k];
|
||||
// Type-tolerant merge: only adopt when the basic shape matches the default.
|
||||
if (Array.isArray(def)) {
|
||||
if (Array.isArray(v)) out[k] = v;
|
||||
} else if (typeof def === 'object') {
|
||||
if (typeof v === 'object' && !Array.isArray(v)) out[k] = v;
|
||||
} else if (typeof def === typeof v) {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out as unknown as PersistedSettings;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
...DEFAULT_PERSISTED,
|
||||
identities: [],
|
||||
@@ -128,24 +318,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedSettings>;
|
||||
set({
|
||||
externalContentPolicy: parsed.externalContentPolicy ?? DEFAULT_PERSISTED.externalContentPolicy,
|
||||
trustedSenders: Array.isArray(parsed.trustedSenders) ? parsed.trustedSenders : [],
|
||||
senderFavicons: typeof parsed.senderFavicons === 'boolean' ? parsed.senderFavicons : DEFAULT_PERSISTED.senderFavicons,
|
||||
groupContactsByLetter: typeof parsed.groupContactsByLetter === 'boolean' ? parsed.groupContactsByLetter : DEFAULT_PERSISTED.groupContactsByLetter,
|
||||
theme: parsed.theme ?? DEFAULT_PERSISTED.theme,
|
||||
fontSize: parsed.fontSize ?? DEFAULT_PERSISTED.fontSize,
|
||||
density: parsed.density ?? DEFAULT_PERSISTED.density,
|
||||
showToolbarLabels: typeof parsed.showToolbarLabels === 'boolean' ? parsed.showToolbarLabels : DEFAULT_PERSISTED.showToolbarLabels,
|
||||
animationsEnabled: typeof parsed.animationsEnabled === 'boolean' ? parsed.animationsEnabled : DEFAULT_PERSISTED.animationsEnabled,
|
||||
emailAlwaysLightMode: typeof parsed.emailAlwaysLightMode === 'boolean' ? parsed.emailAlwaysLightMode : DEFAULT_PERSISTED.emailAlwaysLightMode,
|
||||
autoSelectReplyIdentity: typeof parsed.autoSelectReplyIdentity === 'boolean' ? parsed.autoSelectReplyIdentity : DEFAULT_PERSISTED.autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled: typeof parsed.attachmentReminderEnabled === 'boolean' ? parsed.attachmentReminderEnabled : DEFAULT_PERSISTED.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
|
||||
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
|
||||
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
|
||||
hydrated: true,
|
||||
});
|
||||
set({ ...mergeWithDefaults(parsed), hydrated: true });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -154,6 +327,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
set({ hydrated: true });
|
||||
},
|
||||
|
||||
updateSetting: (key, value) => {
|
||||
set({ [key]: value } as Partial<SettingsState>);
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); },
|
||||
setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); },
|
||||
setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); },
|
||||
@@ -168,6 +346,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
|
||||
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
|
||||
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
|
||||
setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); },
|
||||
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
|
||||
|
||||
addTrustedSender: (email) => {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
@@ -189,6 +369,34 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
return get().trustedSenders.includes(normalized);
|
||||
},
|
||||
|
||||
addSidebarApp: (app) => {
|
||||
const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
set({ sidebarApps: [...get().sidebarApps, { ...app, id }] });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
updateSidebarApp: (id, updates) => {
|
||||
set({
|
||||
sidebarApps: get().sidebarApps.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
||||
});
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
removeSidebarApp: (id) => {
|
||||
set({ sidebarApps: get().sidebarApps.filter((a) => a.id !== id) });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
reorderSidebarApps: (apps) => {
|
||||
set({ sidebarApps: apps });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
setPluginEnabled: (id, enabled) => {
|
||||
set({ pluginEnabled: { ...get().pluginEnabled, [id]: enabled } });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
reset: () => set({
|
||||
identities: [],
|
||||
loading: false,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Mobile-side templates store. Mirrors the webmail's `template-store` but
|
||||
// keeps the surface minimal — categories, defaults, placeholders, and the
|
||||
// favourite/recent tracking are not yet exposed in the mobile UI. We persist
|
||||
// the same shape so an export from one platform imports cleanly into the
|
||||
// other.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
category: string;
|
||||
isFavorite: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'webmail:templates:v1';
|
||||
|
||||
interface TemplatesState {
|
||||
templates: EmailTemplate[];
|
||||
hydrated: boolean;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
addTemplate: (data: Pick<EmailTemplate, 'name' | 'subject' | 'body'> & Partial<Pick<EmailTemplate, 'category' | 'isFavorite'>>) => EmailTemplate;
|
||||
updateTemplate: (id: string, updates: Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>) => void;
|
||||
deleteTemplate: (id: string) => void;
|
||||
exportAll: () => string;
|
||||
importTemplates: (json: string) => { count: number; error?: string };
|
||||
}
|
||||
|
||||
function persist(state: EmailTemplate[]): void {
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ templates: state })).catch((err) => {
|
||||
console.warn('[templates-store] persist failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export const useTemplatesStore = create<TemplatesState>((set, get) => ({
|
||||
templates: [],
|
||||
hydrated: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { templates?: EmailTemplate[] };
|
||||
if (Array.isArray(parsed.templates)) {
|
||||
set({ templates: parsed.templates, hydrated: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[templates-store] hydrate failed', err);
|
||||
}
|
||||
set({ hydrated: true });
|
||||
},
|
||||
|
||||
addTemplate: (data) => {
|
||||
const now = new Date().toISOString();
|
||||
const template: EmailTemplate = {
|
||||
id: genId(),
|
||||
name: data.name,
|
||||
subject: data.subject,
|
||||
body: data.body,
|
||||
category: data.category ?? '',
|
||||
isFavorite: data.isFavorite ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const next = [...get().templates, template];
|
||||
set({ templates: next });
|
||||
persist(next);
|
||||
return template;
|
||||
},
|
||||
|
||||
updateTemplate: (id, updates) => {
|
||||
const next = get().templates.map((t) =>
|
||||
t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : t,
|
||||
);
|
||||
set({ templates: next });
|
||||
persist(next);
|
||||
},
|
||||
|
||||
deleteTemplate: (id) => {
|
||||
const next = get().templates.filter((t) => t.id !== id);
|
||||
set({ templates: next });
|
||||
persist(next);
|
||||
},
|
||||
|
||||
exportAll: () => {
|
||||
return JSON.stringify({ version: 1, templates: get().templates }, null, 2);
|
||||
},
|
||||
|
||||
importTemplates: (json) => {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as { templates?: unknown };
|
||||
if (!Array.isArray(parsed.templates)) {
|
||||
return { count: 0, error: 'Invalid template file: expected `templates` array' };
|
||||
}
|
||||
const incoming: EmailTemplate[] = [];
|
||||
for (const raw of parsed.templates) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const t = raw as Partial<EmailTemplate>;
|
||||
if (typeof t.name !== 'string' || typeof t.body !== 'string') continue;
|
||||
const now = new Date().toISOString();
|
||||
incoming.push({
|
||||
id: typeof t.id === 'string' ? t.id : genId(),
|
||||
name: t.name,
|
||||
subject: typeof t.subject === 'string' ? t.subject : '',
|
||||
body: t.body,
|
||||
category: typeof t.category === 'string' ? t.category : '',
|
||||
isFavorite: !!t.isFavorite,
|
||||
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
|
||||
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
|
||||
});
|
||||
}
|
||||
const existing = get().templates;
|
||||
const existingIds = new Set(existing.map((t) => t.id));
|
||||
const merged = [
|
||||
...existing,
|
||||
...incoming.map((t) => (existingIds.has(t.id) ? { ...t, id: genId() } : t)),
|
||||
];
|
||||
set({ templates: merged });
|
||||
persist(merged);
|
||||
return { count: incoming.length };
|
||||
} catch (err) {
|
||||
return { count: 0, error: err instanceof Error ? err.message : 'Parse failed' };
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -7,6 +7,7 @@ import { downloadAndInstallApk, type InstallProgress } from '../lib/install-upda
|
||||
|
||||
const STORAGE_KEY = 'webmail:updates:v1';
|
||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const PENDING_APK_INTERVAL_MS = 2 * 60 * 1000;
|
||||
|
||||
interface PersistedUpdates {
|
||||
autoCheck: boolean;
|
||||
@@ -82,7 +83,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
const s = get();
|
||||
if (s.checking) return;
|
||||
const now = Date.now();
|
||||
if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return;
|
||||
const waitingForApk =
|
||||
s.cachedLatest != null && !s.cachedLatest.apkAsset && get().hasUpdate();
|
||||
const interval = waitingForApk ? PENDING_APK_INTERVAL_MS : MIN_CHECK_INTERVAL_MS;
|
||||
if (!opts?.force && now - s.lastCheckedAt < interval) return;
|
||||
set({ checking: true, error: null });
|
||||
try {
|
||||
const latest = await fetchLatestRelease();
|
||||
|
||||
+30
-42
@@ -1,45 +1,33 @@
|
||||
export const colors = {
|
||||
// Primary brand colors (matching Bulwark web)
|
||||
primary: '#3b82f6',
|
||||
primaryDark: '#2563eb',
|
||||
primaryLight: '#60a5fa',
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { LIGHT_COLORS, DARK_COLORS, type ThemePalette } from './tokens';
|
||||
|
||||
// Backgrounds
|
||||
background: '#ffffff',
|
||||
surface: '#f8fafc',
|
||||
surfaceHover: '#f1f5f9',
|
||||
export type { ThemePalette };
|
||||
|
||||
// Text
|
||||
text: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
textInverse: '#ffffff',
|
||||
/**
|
||||
* Returns the active palette for the current render. Resolves the user's
|
||||
* theme preference ('light' | 'dark' | 'system') against the OS scheme.
|
||||
*
|
||||
* Prefer this hook in new and migrated components. Static `import { colors }`
|
||||
* still works for not-yet-migrated code (it always returns the dark palette).
|
||||
*/
|
||||
export function useColors(): ThemePalette {
|
||||
const themePref = useSettingsStore((s) => s.theme);
|
||||
const systemScheme = useColorScheme();
|
||||
const resolved =
|
||||
themePref === 'system'
|
||||
? systemScheme === 'light' ? 'light' : 'dark'
|
||||
: themePref;
|
||||
return resolved === 'light' ? LIGHT_COLORS : DARK_COLORS;
|
||||
}
|
||||
|
||||
// Borders
|
||||
border: '#e2e8f0',
|
||||
borderLight: '#f1f5f9',
|
||||
|
||||
// Status
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
|
||||
// Email-specific
|
||||
unread: '#0f172a',
|
||||
read: '#64748b',
|
||||
starred: '#f59e0b',
|
||||
flagged: '#ef4444',
|
||||
|
||||
// Dark mode
|
||||
dark: {
|
||||
background: '#0f172a',
|
||||
surface: '#1e293b',
|
||||
surfaceHover: '#334155',
|
||||
text: '#f8fafc',
|
||||
textSecondary: '#94a3b8',
|
||||
textMuted: '#64748b',
|
||||
border: '#334155',
|
||||
borderLight: '#1e293b',
|
||||
},
|
||||
} as const;
|
||||
/**
|
||||
* Resolves the user's theme preference to a concrete 'light' | 'dark'.
|
||||
* Useful for components that need to choose icons/imagery rather than colors.
|
||||
*/
|
||||
export function useResolvedTheme(): 'light' | 'dark' {
|
||||
const themePref = useSettingsStore((s) => s.theme);
|
||||
const systemScheme = useColorScheme();
|
||||
if (themePref === 'system') return systemScheme === 'light' ? 'light' : 'dark';
|
||||
return themePref;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Dynamic theme hooks that read settings the user can change at runtime.
|
||||
// The static tokens in `./tokens` cover the baseline; these hooks return
|
||||
// scaled / preference-driven variants so screens can react to settings
|
||||
// without a relaunch.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { typography as baseTypography, spacing as baseSpacing } from './tokens';
|
||||
|
||||
export type FontScale = 'small' | 'medium' | 'large';
|
||||
export type DensityKind = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
|
||||
const FONT_SCALE: Record<FontScale, number> = {
|
||||
small: 0.92,
|
||||
medium: 1,
|
||||
large: 1.12,
|
||||
};
|
||||
|
||||
// How tall an email-list-style row should be. Mirrors the webmail
|
||||
// `--density-item-py` token, halved for the per-side padding.
|
||||
const ROW_PADY: Record<DensityKind, number> = {
|
||||
'extra-compact': 4,
|
||||
compact: 8,
|
||||
regular: 12,
|
||||
comfortable: 16,
|
||||
};
|
||||
|
||||
// Vertical gap between rows (used for the FlatList separator strength too).
|
||||
const ROW_GAP: Record<DensityKind, number> = {
|
||||
'extra-compact': 2,
|
||||
compact: 6,
|
||||
regular: 10,
|
||||
comfortable: 14,
|
||||
};
|
||||
|
||||
function scaleStyle(
|
||||
style: { fontSize: number; lineHeight: number; fontWeight: '400' | '500' | '600' | '700' },
|
||||
factor: number,
|
||||
) {
|
||||
return {
|
||||
fontSize: Math.round(style.fontSize * factor),
|
||||
lineHeight: Math.round(style.lineHeight * factor),
|
||||
fontWeight: style.fontWeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTypography() {
|
||||
const fontSize = useSettingsStore((s) => s.fontSize);
|
||||
return useMemo(() => {
|
||||
const f = FONT_SCALE[fontSize] ?? 1;
|
||||
if (f === 1) return baseTypography;
|
||||
return {
|
||||
h1: scaleStyle(baseTypography.h1, f),
|
||||
h2: scaleStyle(baseTypography.h2, f),
|
||||
h3: scaleStyle(baseTypography.h3, f),
|
||||
body: scaleStyle(baseTypography.body, f),
|
||||
bodyMedium: scaleStyle(baseTypography.bodyMedium, f),
|
||||
bodySemibold: scaleStyle(baseTypography.bodySemibold, f),
|
||||
bodyBold: scaleStyle(baseTypography.bodyBold, f),
|
||||
base: scaleStyle(baseTypography.base, f),
|
||||
baseMedium: scaleStyle(baseTypography.baseMedium, f),
|
||||
caption: scaleStyle(baseTypography.caption, f),
|
||||
captionMedium: scaleStyle(baseTypography.captionMedium, f),
|
||||
small: scaleStyle(baseTypography.small, f),
|
||||
tabLabel: scaleStyle(baseTypography.tabLabel, f),
|
||||
};
|
||||
}, [fontSize]);
|
||||
}
|
||||
|
||||
export function useDensity() {
|
||||
const density = useSettingsStore((s) => s.density);
|
||||
return useMemo(
|
||||
() => ({
|
||||
kind: density,
|
||||
rowPaddingVertical: ROW_PADY[density] ?? ROW_PADY.regular,
|
||||
rowGap: ROW_GAP[density] ?? ROW_GAP.regular,
|
||||
// Compact modes hide secondary content so rows breathe less. The
|
||||
// webmail uses extra-compact to drop preview lines entirely.
|
||||
showPreview: density !== 'extra-compact' && density !== 'compact',
|
||||
showAvatar: density !== 'extra-compact',
|
||||
verticalSpacing: Math.max(2, baseSpacing.sm - (4 - ROW_PADY[density] / 4)),
|
||||
}),
|
||||
[density],
|
||||
);
|
||||
}
|
||||
|
||||
// Returns whether the user has opted into in-app animations. When false,
|
||||
// callers should pass duration=0 to `Animated.timing` so transitions snap.
|
||||
export function useShouldAnimate() {
|
||||
return useSettingsStore((s) => s.animationsEnabled);
|
||||
}
|
||||
|
||||
// Convenience: returns `requested` when animations are on, otherwise 0.
|
||||
// Use as `Animated.timing(v, { duration: useAnimDuration(240), ... })`.
|
||||
export function useAnimDuration(requested: number): number {
|
||||
const enabled = useShouldAnimate();
|
||||
return enabled ? requested : 0;
|
||||
}
|
||||
+253
-121
@@ -1,194 +1,326 @@
|
||||
// Qui Dark Theme - exact values from Bulwark Webmail's builtin-themes.ts
|
||||
export const colors = {
|
||||
// Primary brand (Qui theme)
|
||||
// Mirrors the webmail design tokens defined in app/globals.css.
|
||||
// Two palettes (light + dark) live here; consumers should call `useColors()`
|
||||
// from theme/colors.tsx to pick the active one based on the user's setting.
|
||||
// The `colors` export below is kept as a "dark-default" fallback for
|
||||
// not-yet-migrated StyleSheets so they keep rendering while migration is
|
||||
// underway.
|
||||
|
||||
interface CalendarColors {
|
||||
blue: string; purple: string; green: string; orange: string;
|
||||
red: string; pink: string; teal: string; indigo: string;
|
||||
purpleBg: string; tealBg: string; indigoBg: string; pinkBg: string; orangeBg: string;
|
||||
}
|
||||
|
||||
interface TagColor { dot: string; bg: string; text: string; }
|
||||
interface TagColors {
|
||||
red: TagColor; orange: TagColor; yellow: TagColor; green: TagColor;
|
||||
blue: TagColor; purple: TagColor; pink: TagColor; teal: TagColor;
|
||||
cyan: TagColor; indigo: TagColor; amber: TagColor; lime: TagColor; gray: TagColor;
|
||||
}
|
||||
|
||||
export interface ThemePalette {
|
||||
// Brand
|
||||
primary: string;
|
||||
primaryDark: string;
|
||||
primaryLight: string;
|
||||
primaryBg: string;
|
||||
primaryBgHover: string;
|
||||
primaryForeground: string;
|
||||
primaryBorder: string;
|
||||
|
||||
// Surfaces
|
||||
background: string;
|
||||
surface: string;
|
||||
surfaceHover: string;
|
||||
surfaceActive: string;
|
||||
card: string;
|
||||
cardForeground: string;
|
||||
|
||||
// Text
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
textInverse: string;
|
||||
textLink: string;
|
||||
|
||||
// Borders
|
||||
border: string;
|
||||
borderLight: string;
|
||||
borderFocus: string;
|
||||
|
||||
// Status
|
||||
success: string;
|
||||
successBg: string;
|
||||
successForeground: string;
|
||||
warning: string;
|
||||
warningBg: string;
|
||||
warningForeground: string;
|
||||
error: string;
|
||||
errorBg: string;
|
||||
errorForeground: string;
|
||||
errorBorder: string;
|
||||
info: string;
|
||||
infoForeground: string;
|
||||
|
||||
// Email-specific
|
||||
unread: string;
|
||||
read: string;
|
||||
starred: string;
|
||||
flagged: string;
|
||||
draft: string;
|
||||
|
||||
// Calendar
|
||||
calendar: CalendarColors;
|
||||
|
||||
// Tags
|
||||
tags: TagColors;
|
||||
|
||||
// Navigation
|
||||
navActive: string;
|
||||
navInactive: string;
|
||||
navBadge: string;
|
||||
|
||||
// Secondary / muted / accent
|
||||
secondary: string;
|
||||
secondaryForeground: string;
|
||||
muted: string;
|
||||
mutedForeground: string;
|
||||
accent: string;
|
||||
accentForeground: string;
|
||||
|
||||
// Selection
|
||||
selection: string;
|
||||
selectionForeground: string;
|
||||
|
||||
// Popover
|
||||
popover: string;
|
||||
popoverForeground: string;
|
||||
|
||||
// Charts
|
||||
chart1: string; chart2: string; chart3: string; chart4: string; chart5: string;
|
||||
}
|
||||
|
||||
const TAGS: TagColors = {
|
||||
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
|
||||
orange: { dot: '#f97316', bg: 'rgba(67,20,7,0.30)', text: '#fb923c' },
|
||||
yellow: { dot: '#eab308', bg: 'rgba(66,32,6,0.30)', text: '#fbbf24' },
|
||||
green: { dot: '#22c55e', bg: 'rgba(5,46,22,0.30)', text: '#4ade80' },
|
||||
blue: { dot: '#3b82f6', bg: 'rgba(23,37,84,0.30)', text: '#60a5fa' },
|
||||
purple: { dot: '#a855f7', bg: 'rgba(59,7,100,0.30)', text: '#c084fc' },
|
||||
pink: { dot: '#ec4899', bg: 'rgba(80,7,36,0.30)', text: '#f472b6' },
|
||||
teal: { dot: '#14b8a6', bg: 'rgba(4,47,46,0.30)', text: '#2dd4bf' },
|
||||
cyan: { dot: '#06b6d4', bg: 'rgba(8,51,68,0.30)', text: '#22d3ee' },
|
||||
indigo: { dot: '#6366f1', bg: 'rgba(30,27,75,0.30)', text: '#818cf8' },
|
||||
amber: { dot: '#f59e0b', bg: 'rgba(69,26,3,0.30)', text: '#fbbf24' },
|
||||
lime: { dot: '#84cc16', bg: 'rgba(26,46,5,0.30)', text: '#a3e635' },
|
||||
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
|
||||
};
|
||||
|
||||
export const DARK_COLORS: ThemePalette = {
|
||||
primary: '#3b82f6',
|
||||
primaryDark: '#2563eb',
|
||||
primaryLight: '#60a5fa',
|
||||
primaryBg: '#172554',
|
||||
primaryBgHover: '#1e40af',
|
||||
primaryForeground: '#ffffff',
|
||||
primaryBorder: 'rgba(59, 130, 246, 0.19)',
|
||||
|
||||
// Backgrounds (Qui dark)
|
||||
background: '#09090b',
|
||||
surface: '#18181b', // --color-secondary / --color-muted
|
||||
surface: '#18181b',
|
||||
surfaceHover: '#1f1f23',
|
||||
surfaceActive: '#27272a',
|
||||
card: '#141414', // --color-card
|
||||
card: '#141414',
|
||||
cardForeground: '#fafafa',
|
||||
|
||||
// Text (Qui dark)
|
||||
text: '#fafafa', // --color-foreground
|
||||
textSecondary: '#a1a1aa', // --color-muted-foreground
|
||||
text: '#fafafa',
|
||||
textSecondary: '#a1a1aa',
|
||||
textMuted: '#71717a',
|
||||
textInverse: '#ffffff',
|
||||
textInverse: '#0f172a',
|
||||
textLink: '#60a5fa',
|
||||
|
||||
// Borders (Qui dark)
|
||||
border: '#27272a', // --color-border / --color-input
|
||||
border: '#27272a',
|
||||
borderLight: '#18181b',
|
||||
borderFocus: '#3b82f6', // --color-ring
|
||||
borderFocus: '#3b82f6',
|
||||
|
||||
// Status
|
||||
success: '#16a34a',
|
||||
successBg: '#052e16',
|
||||
successForeground: '#ffffff',
|
||||
warning: '#ca8a04',
|
||||
warningBg: '#422006',
|
||||
warningForeground: '#ffffff',
|
||||
error: '#ef4444', // --color-destructive
|
||||
error: '#ef4444',
|
||||
errorBg: '#450a0a',
|
||||
errorForeground: '#fafafa',
|
||||
errorBorder: 'rgba(239, 68, 68, 0.25)',
|
||||
info: '#60a5fa',
|
||||
infoForeground: '#ffffff',
|
||||
|
||||
// Email-specific
|
||||
unread: '#60a5fa', // --color-unread
|
||||
unread: '#60a5fa',
|
||||
read: '#a1a1aa',
|
||||
starred: '#fbbf24', // amber-400
|
||||
starred: '#fbbf24',
|
||||
flagged: '#ef4444',
|
||||
draft: '#a78bfa',
|
||||
// Border variants (for inline opacity hacks)
|
||||
primaryBorder: 'rgba(59, 130, 246, 0.19)', // primary + '30'
|
||||
errorBorder: 'rgba(239, 68, 68, 0.25)', // error + '40'
|
||||
|
||||
// Calendar event colors
|
||||
calendar: {
|
||||
blue: '#60a5fa',
|
||||
purple: '#a78bfa',
|
||||
green: '#4ade80',
|
||||
orange: '#fbbf24',
|
||||
red: '#f87171',
|
||||
pink: '#f472b6',
|
||||
teal: '#2dd4bf',
|
||||
indigo: '#818cf8',
|
||||
// Dark-mode backgrounds for colored icons (replaces hardcoded light colors)
|
||||
purpleBg: 'rgba(167, 139, 250, 0.08)', // purple-950/30 dark
|
||||
tealBg: 'rgba(45, 212, 191, 0.08)', // teal-950/30 dark
|
||||
indigoBg: 'rgba(129, 140, 248, 0.08)', // indigo-950/30 dark
|
||||
pinkBg: 'rgba(244, 114, 182, 0.08)', // pink-950/30 dark
|
||||
orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark
|
||||
blue: '#60a5fa', purple: '#a78bfa', green: '#4ade80', orange: '#fbbf24',
|
||||
red: '#f87171', pink: '#f472b6', teal: '#2dd4bf', indigo: '#818cf8',
|
||||
purpleBg: 'rgba(167, 139, 250, 0.08)',
|
||||
tealBg: 'rgba(45, 212, 191, 0.08)',
|
||||
indigoBg: 'rgba(129, 140, 248, 0.08)',
|
||||
pinkBg: 'rgba(244, 114, 182, 0.08)',
|
||||
orangeBg: 'rgba(251, 191, 36, 0.08)',
|
||||
},
|
||||
|
||||
// Tag colors - from KEYWORD_PALETTE in stores/settings-store.ts
|
||||
// dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400
|
||||
tags: {
|
||||
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
|
||||
orange: { dot: '#f97316', bg: 'rgba(67,20,7,0.30)', text: '#fb923c' },
|
||||
yellow: { dot: '#eab308', bg: 'rgba(66,32,6,0.30)', text: '#fbbf24' },
|
||||
green: { dot: '#22c55e', bg: 'rgba(5,46,22,0.30)', text: '#4ade80' },
|
||||
blue: { dot: '#3b82f6', bg: 'rgba(23,37,84,0.30)', text: '#60a5fa' },
|
||||
purple: { dot: '#a855f7', bg: 'rgba(59,7,100,0.30)', text: '#c084fc' },
|
||||
pink: { dot: '#ec4899', bg: 'rgba(80,7,36,0.30)', text: '#f472b6' },
|
||||
teal: { dot: '#14b8a6', bg: 'rgba(4,47,46,0.30)', text: '#2dd4bf' },
|
||||
cyan: { dot: '#06b6d4', bg: 'rgba(8,51,68,0.30)', text: '#22d3ee' },
|
||||
indigo: { dot: '#6366f1', bg: 'rgba(30,27,75,0.30)', text: '#818cf8' },
|
||||
amber: { dot: '#f59e0b', bg: 'rgba(69,26,3,0.30)', text: '#fbbf24' },
|
||||
lime: { dot: '#84cc16', bg: 'rgba(26,46,5,0.30)', text: '#a3e635' },
|
||||
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
|
||||
},
|
||||
tags: TAGS,
|
||||
|
||||
// Navigation - matches web nav-rail
|
||||
navActive: '#3b82f6',
|
||||
navInactive: '#71717a', // --color-muted-foreground
|
||||
navBadge: '#ef4444', // red-500
|
||||
navInactive: '#71717a',
|
||||
navBadge: '#ef4444',
|
||||
|
||||
// Secondary / Muted (Qui dark)
|
||||
secondary: '#18181b',
|
||||
secondaryForeground: '#fafafa',
|
||||
muted: '#18181b',
|
||||
mutedForeground: '#a1a1aa',
|
||||
|
||||
// Accent (Qui dark)
|
||||
accent: '#172554',
|
||||
accentForeground: '#93c5fd',
|
||||
|
||||
// Selection (Qui dark)
|
||||
selection: 'rgba(59, 130, 246, 0.25)',
|
||||
selectionForeground: '#93c5fd',
|
||||
|
||||
// Popover (Qui dark)
|
||||
popover: '#18181b',
|
||||
popoverForeground: '#fafafa',
|
||||
|
||||
// Chart colors (Qui dark)
|
||||
chart1: '#60a5fa',
|
||||
chart2: '#4ade80',
|
||||
chart3: '#fbbf24',
|
||||
chart4: '#f87171',
|
||||
chart5: '#a78bfa',
|
||||
} as const;
|
||||
chart1: '#60a5fa', chart2: '#4ade80', chart3: '#fbbf24', chart4: '#f87171', chart5: '#a78bfa',
|
||||
};
|
||||
|
||||
const LIGHT_TAGS: TagColors = {
|
||||
// For light mode, dot stays as the saturated swatch but bg gets lighter and text gets darker.
|
||||
red: { dot: '#ef4444', bg: 'rgba(254,226,226,0.6)', text: '#b91c1c' },
|
||||
orange: { dot: '#f97316', bg: 'rgba(254,237,213,0.6)', text: '#c2410c' },
|
||||
yellow: { dot: '#eab308', bg: 'rgba(254,249,195,0.6)', text: '#a16207' },
|
||||
green: { dot: '#22c55e', bg: 'rgba(220,252,231,0.6)', text: '#15803d' },
|
||||
blue: { dot: '#3b82f6', bg: 'rgba(219,234,254,0.6)', text: '#1d4ed8' },
|
||||
purple: { dot: '#a855f7', bg: 'rgba(243,232,255,0.6)', text: '#7e22ce' },
|
||||
pink: { dot: '#ec4899', bg: 'rgba(252,231,243,0.6)', text: '#be185d' },
|
||||
teal: { dot: '#14b8a6', bg: 'rgba(204,251,241,0.6)', text: '#0f766e' },
|
||||
cyan: { dot: '#06b6d4', bg: 'rgba(207,250,254,0.6)', text: '#0e7490' },
|
||||
indigo: { dot: '#6366f1', bg: 'rgba(224,231,255,0.6)', text: '#4338ca' },
|
||||
amber: { dot: '#f59e0b', bg: 'rgba(254,243,199,0.6)', text: '#b45309' },
|
||||
lime: { dot: '#84cc16', bg: 'rgba(236,252,203,0.6)', text: '#4d7c0f' },
|
||||
gray: { dot: '#6b7280', bg: 'rgba(243,244,246,0.6)', text: '#374151' },
|
||||
};
|
||||
|
||||
export const LIGHT_COLORS: ThemePalette = {
|
||||
primary: '#3b82f6',
|
||||
primaryDark: '#2563eb',
|
||||
primaryLight: '#60a5fa',
|
||||
primaryBg: '#dbeafe',
|
||||
primaryBgHover: '#bfdbfe',
|
||||
primaryForeground: '#ffffff',
|
||||
primaryBorder: 'rgba(59, 130, 246, 0.25)',
|
||||
|
||||
background: '#ffffff',
|
||||
surface: '#f8fafc',
|
||||
surfaceHover: '#f1f5f9',
|
||||
surfaceActive: '#e2e8f0',
|
||||
card: '#ffffff',
|
||||
cardForeground: '#0f172a',
|
||||
|
||||
text: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
textInverse: '#ffffff',
|
||||
textLink: '#1d4ed8',
|
||||
|
||||
border: '#e2e8f0',
|
||||
borderLight: '#f1f5f9',
|
||||
borderFocus: '#94a3b8',
|
||||
|
||||
success: '#22c55e',
|
||||
successBg: '#dcfce7',
|
||||
successForeground: '#ffffff',
|
||||
warning: '#eab308',
|
||||
warningBg: '#fef9c3',
|
||||
warningForeground: '#ffffff',
|
||||
error: '#ef4444',
|
||||
errorBg: '#fee2e2',
|
||||
errorForeground: '#ffffff',
|
||||
errorBorder: 'rgba(239, 68, 68, 0.30)',
|
||||
info: '#3b82f6',
|
||||
infoForeground: '#ffffff',
|
||||
|
||||
unread: '#3b82f6',
|
||||
read: '#64748b',
|
||||
starred: '#eab308',
|
||||
flagged: '#ef4444',
|
||||
draft: '#a855f7',
|
||||
|
||||
calendar: {
|
||||
blue: '#3b82f6', purple: '#a855f7', green: '#22c55e', orange: '#f59e0b',
|
||||
red: '#ef4444', pink: '#ec4899', teal: '#14b8a6', indigo: '#6366f1',
|
||||
purpleBg: 'rgba(168, 85, 247, 0.10)',
|
||||
tealBg: 'rgba(20, 184, 166, 0.10)',
|
||||
indigoBg: 'rgba(99, 102, 241, 0.10)',
|
||||
pinkBg: 'rgba(236, 72, 153, 0.10)',
|
||||
orangeBg: 'rgba(245, 158, 11, 0.10)',
|
||||
},
|
||||
|
||||
tags: LIGHT_TAGS,
|
||||
|
||||
navActive: '#3b82f6',
|
||||
navInactive: '#64748b',
|
||||
navBadge: '#ef4444',
|
||||
|
||||
secondary: '#f8fafc',
|
||||
secondaryForeground: '#0f172a',
|
||||
muted: '#f1f5f9',
|
||||
mutedForeground: '#64748b',
|
||||
accent: '#dbeafe',
|
||||
accentForeground: '#1e40af',
|
||||
|
||||
selection: 'rgba(59, 130, 246, 0.18)',
|
||||
selectionForeground: '#1e40af',
|
||||
|
||||
popover: '#ffffff',
|
||||
popoverForeground: '#0f172a',
|
||||
|
||||
chart1: '#3b82f6', chart2: '#22c55e', chart3: '#f59e0b', chart4: '#ef4444', chart5: '#8b5cf6',
|
||||
};
|
||||
|
||||
// Backwards-compat: not-yet-migrated StyleSheets keep importing `colors` and
|
||||
// see the dark palette. Migrate component-by-component to `useColors()`.
|
||||
export const colors = DARK_COLORS;
|
||||
|
||||
// Spacing matches Tailwind's rem scale converted to px
|
||||
export const spacing = {
|
||||
xs: 4, // 0.25rem
|
||||
sm: 8, // 0.5rem
|
||||
md: 12, // 0.75rem
|
||||
lg: 16, // 1rem
|
||||
xl: 20, // 1.25rem
|
||||
xxl: 24, // 1.5rem
|
||||
xxxl: 32, // 2rem
|
||||
xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, xxxl: 32,
|
||||
} as const;
|
||||
|
||||
// Border radii matching Tailwind's rounded-* scale
|
||||
export const radius = {
|
||||
xs: 4, // rounded
|
||||
sm: 6, // rounded-md
|
||||
md: 8, // rounded-lg
|
||||
lg: 12, // rounded-xl
|
||||
xl: 16, // rounded-2xl
|
||||
full: 9999, // rounded-full
|
||||
xs: 4, sm: 6, md: 8, lg: 12, xl: 16, full: 9999,
|
||||
} as const;
|
||||
|
||||
// Typography matching web's Tailwind text-* classes
|
||||
export const typography = {
|
||||
// Headings
|
||||
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl
|
||||
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl
|
||||
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg
|
||||
// Body = text-sm (14px) - the web uses text-sm for almost all content
|
||||
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 },
|
||||
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 },
|
||||
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 },
|
||||
body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
|
||||
bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 },
|
||||
bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 },
|
||||
bodyBold: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 },
|
||||
// Base = text-base (16px) - used for email content body
|
||||
base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
|
||||
baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 },
|
||||
// Caption = text-xs (12px)
|
||||
caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 },
|
||||
captionMedium: { fontSize: 12, fontWeight: '500' as const, lineHeight: 16 },
|
||||
// Small = 10px - for tags, badges, tab labels
|
||||
small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
} as const;
|
||||
|
||||
// Component-specific sizes matching web
|
||||
export const componentSizes = {
|
||||
// Avatars (web: w-8/w-10/w-12)
|
||||
avatarSm: 32,
|
||||
avatarMd: 40,
|
||||
avatarLg: 48,
|
||||
// Nav icons (web: w-5 h-5 = 20px)
|
||||
navIcon: 20,
|
||||
// Status icons (web: w-3.5 h-3.5 = 14px)
|
||||
statusIcon: 14,
|
||||
// Mobile header height (web: h-14 = 56px)
|
||||
headerHeight: 56,
|
||||
// Input height (web: h-10 = 40px)
|
||||
inputHeight: 40,
|
||||
// Button heights
|
||||
buttonSm: 36, // h-9
|
||||
buttonMd: 40, // h-10
|
||||
buttonLg: 44, // h-11
|
||||
// Tag dot (web: w-1.5 h-1.5 = 6px)
|
||||
tagDot: 6,
|
||||
// Event dot (web: w-1.5 h-1.5 = 6px)
|
||||
eventDot: 6,
|
||||
// Toggle switch (web: h-6 w-11)
|
||||
toggleHeight: 24,
|
||||
toggleWidth: 44,
|
||||
toggleThumb: 16,
|
||||
// FAB (standard material)
|
||||
fab: 56,
|
||||
// Nav badge (web: min-w-4 h-4 = 16px)
|
||||
badgeSize: 16,
|
||||
avatarSm: 32, avatarMd: 40, avatarLg: 48,
|
||||
navIcon: 20, statusIcon: 14,
|
||||
headerHeight: 56, inputHeight: 40,
|
||||
buttonSm: 36, buttonMd: 40, buttonLg: 44,
|
||||
tagDot: 6, eventDot: 6,
|
||||
toggleHeight: 24, toggleWidth: 44, toggleThumb: 16,
|
||||
fab: 56, badgeSize: 16,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user