Compare commits

...
25 Commits
Author SHA1 Message Date
Linus Rath 8fa8597e9f chore: update version number to 0.1.15 2026-05-02 21:12:12 +02:00
Linus Rath 57f15f6f2e feat: improve keyboard handling in ComposeScreen for better visibility of format bar 2026-05-02 21:12:03 +02:00
Linus Rath cd3fdfa217 chore: update version number to 0.1.14 2026-05-02 17:22:14 +02:00
Linus Rath f8064d4544 feat: add swipe mode settings and enhance swipeable row functionality 2026-05-02 17:22:05 +02:00
Linus Rath a8bb83ffe0 chore: update version number to 0.1.13 2026-05-02 14:44:00 +02:00
Linus Rath c37256c54e feat: enhance attachment handling in ComposeScreen with file picker and updated UI options 2026-05-02 14:43:45 +02:00
Linus Rath e1d318ebc1 feat: add restoreEmailMailboxes mock to email store tests 2026-05-02 14:32:15 +02:00
Linus Rath 2090480bf4 feat: implement undo functionality for email actions with snackbar notification 2026-05-02 14:31:50 +02:00
Linus Rath 329c8a2f0b feat: add swipe actions for pinning and moving emails; enhance archive functionality 2026-05-02 14:27:15 +02:00
Linus Rath 49d1fc62a8 chore: update version number to 0.1.12 2026-04-27 17:14:32 +02:00
Linus Rath d5a336519a feat: add rich text editor component and enhance email thread screen with attachment handling
- Implemented a new RichTextEditor component for composing and editing rich text emails.
- Enhanced EmailThreadScreen to support downloading attachments with loading indicators.
- Added functionality to expand/collapse attachment list in EmailThreadScreen.
- Created utility functions for building initial HTML for email composition and converting HTML to plain text.
- Added tests for HTML composition and conversion utilities.
2026-04-27 17:14:22 +02:00
Linus Rath 9c7654acb2 chore: update version number to 0.1.11 2026-04-27 13:55:25 +02:00
Linus Rath 346a71de84 feat: enhance email composition with markdown support and attachment handling
- Implement markdown formatting in the email composer, allowing users to use bold, italic, lists, and blockquotes.
- Add functionality to upload and manage attachments, including inline images.
- Introduce a new compose-format utility for converting markdown to HTML.
- Update the ComposeScreen to handle attachments and formatting actions.
- Refactor ContactDetailScreen and EmailListScreen for improved code clarity and performance.
- Add tests for markdown formatting and selection wrapping functionalities.
2026-04-27 13:55:16 +02:00
Linus Rath 9a278f1be3 chore: update version number to 0.1.10 2026-04-27 13:30:57 +02:00
Linus Rath cf3f919fa2 feat: enhance SettingsScreen with dynamic localization and theming
- Integrated useColors hook for dynamic theming in SettingsScreen.
- Updated tab labels and titles to use localized strings.
- Refactored styles to utilize theme palette for consistent styling.
- Improved locale subscription to re-render labels on language change.

feat: implement useSheetDrag hook for bottom sheet gesture handling

- Added useSheetDrag hook to manage drag-to-dismiss functionality for bottom sheets.
- Configured pan responder for handling drag gestures and dismissing the sheet based on distance and velocity.

refactor: restructure color and token management

- Introduced useColors and useResolvedTheme hooks for better theme management.
- Separated light and dark color palettes for improved maintainability.
- Updated tokens to reflect new theming structure and ensure consistency across components.
2026-04-27 13:30:45 +02:00
Linus Rath 792b028b7a chore: update version number to 0.1.9 2026-04-27 11:20:53 +02:00
Linus Rath ac84f13b22 feat: add network connectivity management and offline banner component 2026-04-27 11:20:23 +02:00
Linus Rath 571b28a9f2 chore: update version number to 0.1.8 2026-04-27 00:16:35 +02:00
Linus Rath a95783723a feat: implement keyword management and layout settings for email interactions 2026-04-27 00:16:25 +02:00
Linus Rath b36e065ebe feat: add content senders and language settings components
- Implemented ContentSendersSettings component for managing trusted senders.
- Added LanguageSettings component to allow users to select their preferred language.
- Introduced minimal i18n support with locale detection and translation functions.
- Created a locale store using Zustand for managing locale state and persistence with AsyncStorage.
- Added a script to sync locale JSON files from the parent webmail repository.
2026-04-27 00:06:58 +02:00
Linus Rath 0d7bf3204a chore: update version number to 0.1.7 2026-04-26 22:23:26 +02:00
Linus Rath a76e0fe8c0 feat: enhance APK installation process with progress tracking and UI updates 2026-04-26 22:22:52 +02:00
Linus Rath cee4193e58 chore: update version number to 0.1.6 2026-04-26 21:59:14 +02:00
Linus Rath 5ac2a142b5 feat: update Android build configuration to use arm64-v8a architecture and improve caching mechanism 2026-04-26 21:58:08 +02:00
Linus Rath e73dc4e5ba feat: add GitHub release creation after version bump 2026-04-26 21:40:25 +02:00
115 changed files with 49256 additions and 3087 deletions
+11 -1
View File
@@ -42,6 +42,16 @@ jobs:
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
restore-keys: android-sdk-${{ runner.os }}-
- name: Compute native cache key input
run: |
node -e "
const fs = require('fs');
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
if (lock.packages && lock.packages['']) delete lock.packages[''].version;
delete lock.version;
fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {}));
"
- name: Cache native CMake build outputs
uses: actions/cache@v4
with:
@@ -50,7 +60,7 @@ jobs:
android/app/build/intermediates/cxx
android/app/build/intermediates/merged_native_libs
android/app/build/intermediates/stripped_native_libs
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }}
restore-keys: cxx-${{ runner.os }}-
- name: Install JS dependencies
+31 -18
View File
@@ -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';
@@ -35,9 +36,12 @@ import { useCalendarStore } from './src/stores/calendar-store';
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>();
@@ -53,32 +57,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,
@@ -96,8 +100,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,
@@ -161,6 +165,15 @@ export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const client = useAuthStore((state) => state.client);
const restoreSession = useAuthStore((state) => state.restoreSession);
// Resolve the user's theme preference to a concrete light/dark style for the
// system status bar. The rest of the app's colors are still hard-coded dark
// until the StyleSheet migration to a theme-aware `useColors` hook lands.
const themePref = useSettingsStore((state) => state.theme);
const systemScheme = useColorScheme();
const resolvedScheme: 'light' | 'dark' =
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
// Persisted active account is the signal that the user was already signed
// in on the previous launch. When present we render the main UI with the
// cached mail list instead of the "Restoring session" spinner; the real
@@ -175,6 +188,8 @@ export default function App() {
React.useEffect(() => {
void useSettingsStore.getState().hydrate();
void useLocaleStore.getState().hydrate();
return useNetworkStore.getState().init();
}, []);
React.useEffect(() => {
@@ -305,7 +320,7 @@ export default function App() {
if (!hasRestoredSession && !hasPersistedAccount) {
return (
<>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<LoadingScreen message="Loading..." />
</>
);
@@ -314,7 +329,7 @@ export default function App() {
if (hasRestoredSession && !isAuthenticated) {
return (
<>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<LoginScreen />
</>
);
@@ -322,7 +337,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} />
@@ -370,12 +385,10 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
gap: spacing.md,
},
loadingText: {
...typography.body,
color: colors.textSecondary,
},
});
-227
View File
@@ -1,227 +0,0 @@
# React Native Calendar - Parity Plan
Bring `repos/react-native/` calendar to parity with the webmail calendar at
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus
utility libs; mobile port targets ~2,500 lines with feature parity on the
essentials and explicit deferrals for things that don't fit a phone UX.
## Current state
- `src/screens/CalendarScreenNew.tsx` - month grid + agenda-for-selected-day,
all rendering `MOCK_EVENTS`. Doesn't touch the store.
- `src/stores/calendar-store.ts` - CRUD wiring exists (`fetchCalendars`,
`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
`handleStateChange`, no per-calendar visibility, no range tracking.
- `src/api/calendar.ts` - JMAP methods wired (`Calendar/get`,
`CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`).
- `src/api/types.ts` - `CalendarEvent` and `Calendar` types match webmail.
- No event modal, no detail view, no week/day view, no sidebar, no recurrence.
## In scope
### Phase 1 - Data layer (foundation for everything else)
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
- `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
`CalendarSidebar` toggle state).
- `loadedRange: { after: string; before: string } | null` - track what's fetched
so view changes don't re-fetch unnecessarily.
- `ensureRange(after, before)` - fetch only if the requested window isn't
covered by `loadedRange`; expand the loaded range in place instead of
replacing events wholesale, so switching Month → Week doesn't drop data.
- `handleStateChange(change)` - subscribe to `Calendar` and `CalendarEvent`
typeState keys; on push, refetch calendars and re-query the current window.
Already referenced from `App.tsx` push handler - this finally implements it.
- `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
**`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`:
- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
- `parseDuration(iso8601)` - `PT1H30M` → milliseconds.
- `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }`
handling `showWithoutTime`.
- `eventsOnDay(events, day)` - day overlap check (not just start-matches-day).
- `getCalendarColor(cal)` - fallback to a deterministic palette when `color`
is missing, matching `theme/tokens.ts` `colors.calendar.*`.
**`src/lib/recurrence-expansion.ts`** (new) - minimal port of webmail's RRULE
expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`.
Mobile port: same surface, same tests. This is the hard part - allocate ~400
lines. Drives correct rendering of recurring events across the visible window.
### Phase 2 - Views
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
- On mount: `fetchCalendars()`, then `ensureRange` for visible window.
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone).
- Header shows month/week label; prev/next shifts by the current view's step.
- "Today" button resets both `currentDate` and `selectedDate`.
- FAB opens create modal with `start = selectedDate at next half hour`.
**`src/components/calendar/MonthView.tsx`** (extract from current screen,
~180 lines) - real events, up to 3 colored dots per day, "+N" indicator when
more. Tap day → selects + scrolls agenda below. Long-press day → opens create
modal with that date.
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) - port of
`calendar-week-view.tsx` (511 lines) trimmed for mobile:
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
- All-day strip at top.
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among
overlapping events (port webmail's stacking algorithm).
- Tap event → detail modal. Long-press empty slot → create modal pre-filled.
- Horizontal swipe → prev/next week (react-native-gesture-handler already
available via react-navigation).
- _Deferred_: drag-to-reschedule, drag-to-resize. Phone targets are too small
for it to be better than tap-edit.
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) - port of
`calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
days forward. Taps open detail modal.
### Phase 3 - Event interaction
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) - bottom
sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines
on web - phone version drops the hover card chrome and the inline-edit fields):
- Header: calendar color bar, title, date/time range (or "All day"), calendar name.
- Sections: location, description (linkified), participants with RSVP status,
recurrence summary (e.g. "Weekly on Mon, Wed - until 2026-06-01"), reminders.
- Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
only" / "This and following" / "All events").
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) - full-screen
modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
- Title, calendar picker (color swatches), all-day toggle.
- Start/end date+time pickers (`@react-native-community/datetimepicker` - add dep).
- Location, description (multiline).
- Participant input with autocomplete from contacts store (port of
`participant-input.tsx`, ~180 lines).
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.)
are deferred - covers the 95% case.
- Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
`Alert.trigger.offset`.
- Timezone: default to device TZ; optional override picker hidden behind
"Advanced" disclosure.
- Save path handles "this instance only" override vs master update for
recurring events (write to `recurrenceOverrides[recurrenceId]`).
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) -
direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
edit/delete of a recurring instance.
### Phase 4 - Multi-calendar
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) -
port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
(reuses `SidebarDrawer.tsx`):
- List of calendars with color swatch + visibility toggle + name.
- "My calendars" vs "Subscribed" sections if `myRights` differs.
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
- Footer: "Add calendar" (deferred to Phase 6).
**`src/theme/tokens.ts`** - audit `colors.calendar.*` palette against webmail's
calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
add any missing shades.
### Phase 5 - Polish
- Pull-to-refresh on each view → forces re-fetch of current window.
- Empty states per view (no calendars yet / no events this month).
- Loading skeleton for month grid and week grid.
- Error banner when fetch fails (pattern from `EmailListScreen`).
- Respect `email-store` push via `handleStateChange` so events update live
when another client creates one.
## Explicitly deferred (out of scope)
These are in the webmail but don't make sense for a first mobile pass:
- **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines,
`ical-subscription-modal.tsx` 220 lines) - file picker + URL subscription
with auth flows. Better as a dedicated settings flow later.
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) -
JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
screen, not bolted onto calendar.
- **Drag to reschedule / resize** - mobile tap targets are too small for the
fine-grained control the web drag gives; tap-edit is the mobile idiom.
- **Quick event input** (`quick-event-input.tsx`) - NLP parser ("Lunch tomorrow
at noon"). Neat on desktop, marginal on phone where typing is already slow.
- **Event context menu** (`event-context-menu.tsx`) - right-click is desktop-
only. Actions move into the detail sheet's action row.
- **Mini-calendar** (`mini-calendar.tsx`) - web uses it as a sidebar date
picker. Mobile already has the main month view; redundant.
- **Birthday calendar auto-derived events** - lower priority; leave the
contacts-store integration for a follow-up.
## Dependencies to add
- `@react-native-community/datetimepicker` - native date/time pickers.
- `react-native-reanimated` is already in (via react-navigation); reuse for
bottom-sheet animations on `EventDetailSheet`.
- No new calendar library - reimplement RRULE expansion in ~400 lines rather
than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
## File map (new / modified)
```
repos/react-native/src/
├── screens/
│ └── CalendarScreenNew.tsx [rewrite]
├── components/
│ └── calendar/ [new directory]
│ ├── MonthView.tsx [new]
│ ├── WeekView.tsx [new]
│ ├── AgendaView.tsx [new]
│ ├── EventCard.tsx [new]
│ ├── EventDetailSheet.tsx [new]
│ ├── EventModal.tsx [new]
│ ├── ParticipantInput.tsx [new]
│ ├── RecurrenceScopeDialog.tsx [new]
│ ├── CalendarSidebarDrawer.tsx [new]
│ └── index.ts [new]
├── stores/
│ └── calendar-store.ts [extend]
├── lib/
│ ├── calendar-utils.ts [new]
│ └── recurrence-expansion.ts [new]
└── theme/
└── tokens.ts [audit calendar palette]
```
## Testing approach
- `vitest.config.ts` already exists. Unit tests for:
- `recurrence-expansion` (daily/weekly/monthly, count+until, byDay).
- `calendar-utils` (duration parse, all-day overlap, DST boundary).
- Store: `ensureRange` dedup, `handleStateChange` refetch trigger.
- Manual emulator test matrix:
- Create event (timed, all-day, recurring), verify server round-trip.
- Edit recurring event via scope dialog.
- Delete recurring instance vs series.
- Toggle calendar visibility, confirm events disappear.
- Push update from webmail, confirm mobile refreshes.
## Build order (suggested)
1. Phase 1 in full - nothing else works without real data.
2. MonthView + EventDetailSheet (read path) - first thing user sees.
3. EventModal create path - unblocks manual testing of write path.
4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together.
5. WeekView - biggest UI, easiest to defer if time runs short.
6. EventModal edit + recurrence scope - finishes the edit surface.
7. Phase 5 polish.
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util
changes. Each phase is ship-safe on its own - the app stays functional even if
we stop at Phase 2.
+1 -1
View File
@@ -1 +1 @@
0.1.5
0.1.15
+1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+1 -1
View File
@@ -28,7 +28,7 @@ android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
reactNativeArchitectures=arm64-v8a
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
+1 -1
View File
@@ -45,7 +45,7 @@ module.exports = {
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-secure-store', '@react-native-community/datetimepicker'],
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
extra: {
commit: COMMIT,
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32 -2
View File
@@ -1,16 +1,17 @@
{
"name": "bulwark-mobile",
"version": "0.1.5",
"version": "0.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.5",
"version": "0.1.15",
"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",
@@ -19,6 +20,7 @@
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-localization": "~17.0.8",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
@@ -2792,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",
@@ -5245,6 +5256,19 @@
"expo": "*"
}
},
"node_modules/expo-localization": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
"license": "MIT",
"dependencies": {
"rtl-detect": "^1.0.2"
},
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
@@ -8789,6 +8813,12 @@
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
}
},
"node_modules/rtl-detect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.5",
"version": "0.1.15",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -10,11 +10,13 @@
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest",
"bump": "node scripts/bump-version.mjs"
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
@@ -23,6 +25,7 @@
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-localization": "~17.0.8",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
+6
View File
@@ -54,4 +54,10 @@ gitInherit('commit', '-m', `chore: update version number to ${next}`);
gitInherit('tag', '-a', next, '-m', next);
gitInherit('push', '--follow-tags');
execFileSync(
'gh',
['release', 'create', next, '--title', next, '--generate-notes'],
{ cwd: root, stdio: 'inherit' },
);
console.log(`Released ${next}`);
+43
View File
@@ -0,0 +1,43 @@
// Copy locale JSON files from the parent webmail repo into ./locales/.
// Run from the RN repo root: `node scripts/sync-locales.mjs`.
// In environments where the parent repo isn't checked out (e.g. CI clone of
// the standalone RN repo), this is a no-op — vendored files stay as-is.
import { readdir, mkdir, copyFile, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RN_LOCALES = join(__dirname, '..', 'locales');
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
if (!existsSync(WEBMAIL_LOCALES)) {
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
process.exit(0);
}
async function copyDir(src, dest) {
await mkdir(dest, { recursive: true });
for (const entry of await readdir(src, { withFileTypes: true })) {
const s = join(src, entry.name);
const d = join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(s, d);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
await copyFile(s, d);
}
}
}
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
.filter((e) => e.isDirectory())
.map((e) => e.name);
let copied = 0;
for (const lang of langs) {
const src = join(WEBMAIL_LOCALES, lang);
const dest = join(RN_LOCALES, lang);
await copyDir(src, dest);
copied++;
}
console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`);
+7
View File
@@ -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',
+11 -6
View File
@@ -1,3 +1,4 @@
import { File } from 'expo-file-system';
import { jmapClient } from './jmap-client';
export async function uploadBlob(
@@ -7,11 +8,14 @@ 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 the source via the file-system API so this works for both `file://`
// (image picker, iOS document picker) and `content://` (Android SAF) URIs.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
method: 'POST',
@@ -19,11 +23,12 @@ export async function uploadBlob(
'Content-Type': type,
Authorization: (jmapClient as any).authHeader,
},
body: blob,
body: bytes as unknown as BodyInit,
});
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();
+167 -12
View File
@@ -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 = [
@@ -111,6 +111,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 +302,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 +320,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 +338,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) {
+38 -12
View File
@@ -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;
}
/**
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
});
});
}
+14 -9
View File
@@ -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
},
});
});
}
+13 -8
View File
@@ -6,7 +6,8 @@ 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 { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } 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,6 +130,8 @@ 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);
@@ -247,13 +250,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 +311,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 +321,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 +333,7 @@ const styles = StyleSheet.create({
},
bannerButtonText: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
webContainer: {
width: '100%',
@@ -343,4 +347,5 @@ const styles = StyleSheet.create({
webviewContainer: {
backgroundColor: 'transparent',
},
});
});
}
+16 -11
View File
@@ -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,
},
});
});
}
+199
View File
@@ -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,
},
});
}
+44
View File
@@ -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,
},
});
}
+12 -7
View File
@@ -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,
},
});
});
}
+461
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = url.replace(/"/g, '&quot;');
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, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
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',
},
});
}
+58 -49
View File
@@ -10,7 +10,8 @@ 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 { useEmailStore } from '../stores/email-store';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
@@ -48,21 +49,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 +93,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 +113,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 +140,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);
@@ -237,7 +244,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 +274,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 +291,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 +330,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 +371,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 +389,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 +401,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 +414,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 +427,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 +450,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 +472,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 +486,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 +498,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 +514,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 +523,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 +537,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 +574,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 +594,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 +615,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 +635,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 +658,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 +678,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 +700,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 +715,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,
},
});
});
}
+316
View File
@@ -0,0 +1,316 @@
import React, { useRef, useState } from 'react';
import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
type LucideIcon,
} from 'lucide-react-native';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
/** Right-to-left swipe action (revealed under a leftward drag, sits at right edge). */
leftAction: SwipeAction;
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean; pinned: boolean };
onAction: (action: SwipeAction) => void;
/**
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
* 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
*/
mode?: SwipeMode;
}
// Instant mode: distance the user must drag past on release to fire the action.
const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
const REVEAL_WIDTH = 88;
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
pin: { icon: Pin, bg: '#7c3aed', defaultLabel: 'Pin' },
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
};
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string {
if (action === 'read') return context.unread ? 'Read' : 'Unread';
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin';
if (action === 'none') return '';
return ACTION_META[action].defaultLabel;
}
// Actions that visibly remove the row from the list (so the row should fly off
// instead of snapping back). Toggle-style actions stay in place.
function exitsRow(action: SwipeAction): boolean {
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
// Reveal-mode-only state: which side (if any) is currently sitting open.
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
const close = () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = null;
setOpenSide(null);
};
const openTo = (side: 'left' | 'right') => {
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = side;
setOpenSide(side);
};
const fly = (toValue: number, action: SwipeAction) => {
// For destructive/move actions: race the row off-screen and fire the
// action - the parent will remove the row from the list. For toggle
// actions: fire immediately and snap back so the same row can update in
// place.
if (exitsRow(action)) {
Animated.timing(dx, {
toValue,
duration: 180,
useNativeDriver: true,
}).start(() => {
onAction(action);
// The row is about to be removed; reset translation in case it isn't
// (e.g. the action failed silently) so we don't leave it off-screen.
dx.setValue(0);
});
} else {
onAction(action);
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
};
const fireFromBandTap = (action: SwipeAction) => {
close();
if (action === 'none') return;
if (exitsRow(action)) {
// Let the row collapse a frame, then fire so the parent's list update
// has a clean starting point.
requestAnimationFrame(() => onAction(action));
} else {
onAction(action);
}
};
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
// Reveal mode: if a side is already open, allow the gesture so the
// user can drag it closed.
if (mode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
} else if (mode !== 'reveal') {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
}
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base =
mode === 'reveal'
? openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0
: 0;
const next = base + g.dx;
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
if (mode === 'reveal') {
const base =
openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openTo('right');
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openTo('left');
} else {
close();
}
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
if (mode === 'reveal') close();
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
if (mode === 'reveal') {
// Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
// are tap targets. The row slides over them; releasing past the activation
// threshold snaps the row to the open position so the band is fully
// visible and tappable.
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<Pressable
onPress={() => fireFromBandTap(action)}
style={[
styles.bandReveal,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
]}
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</Pressable>
);
};
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderRevealBand(rightAction, 'left')}
{renderRevealBand(leftAction, 'right')}
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
{/* When an action is revealed, an overlay absorbs taps on the row so
a tap closes the swipe instead of opening the email. */}
{openSide ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
}
// Instant mode: bands fill the gap behind the row as it's dragged.
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
const iconScale = dx.interpolate({
inputRange,
outputRange: side === 'left' ? [0.85, 1.15] : [1.15, 0.85],
extrapolate: 'clamp',
});
return (
<View
style={[
styles.bandInstant,
{ backgroundColor: meta.bg },
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
]}
>
<Animated.View style={[styles.bandInner, { transform: [{ scale: iconScale }] }]}>
<Icon size={22} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</Animated.View>
</View>
);
};
const rightBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
});
const leftBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [1, 0, 0],
extrapolate: 'clamp',
});
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
{renderInstantBand(rightAction, 'left')}
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
{renderInstantBand(leftAction, 'right')}
</Animated.View>
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
</Animated.View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
content: { backgroundColor: c.background },
bandInstant: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
bandReveal: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
});
}
+11 -6
View File
@@ -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,
},
});
});
}
+120
View File
@@ -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,
},
});
}
+17 -10
View File
@@ -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 },
});
});
}
+17 -12
View File
@@ -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,8 @@ 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 { getCalendarColor } from '../../lib/calendar-utils';
interface CalendarSidebarDrawerProps {
@@ -31,6 +32,8 @@ 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;
@@ -89,7 +92,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 +137,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 +157,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 +174,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 +190,7 @@ const styles = StyleSheet.create({
maxWidth: 340,
backgroundColor: '#262626',
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
@@ -195,7 +201,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
headerClose: {
width: 36,
@@ -204,14 +210,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 +225,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 +237,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 +246,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 },
});
}
+18 -13
View File
@@ -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 },
});
}
+64 -42
View File
@@ -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 },
});
}
+50 -43
View File
@@ -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 },
});
}
+16 -11
View File
@@ -12,7 +12,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,
@@ -41,6 +42,8 @@ function MonthViewInner({
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
@@ -120,14 +123,15 @@ function MonthViewInner({
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' },
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
@@ -138,12 +142,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 +164,10 @@ const styles = StyleSheet.create({
borderRadius: componentSizes.eventDot / 2,
},
overflowText: {
color: colors.textMuted,
color: c.textMuted,
fontSize: 9,
lineHeight: 10,
marginLeft: 1,
},
});
});
}
+21 -16
View File
@@ -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,
},
});
});
}
+28 -23
View File
@@ -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,
@@ -49,6 +50,8 @@ function WeekViewInner({
onCreateAtTime,
weekStartsOn = 0,
}: WeekViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const scrollRef = React.useRef<ScrollView>(null);
const index = React.useMemo(
@@ -311,12 +314,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 +332,7 @@ const styles = StyleSheet.create({
},
dayHeaderWeekday: {
...typography.small,
color: colors.textMuted,
color: c.textMuted,
textTransform: 'uppercase',
},
dayHeaderNumber: {
@@ -338,29 +342,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 +380,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 +389,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 +411,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 +425,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 },
});
}
+23 -18
View File
@@ -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 },
});
}
+20 -15
View File
@@ -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 },
});
}
+52 -34
View File
@@ -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 },
});
}
+66 -57
View File
@@ -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,
},
});
}
+18 -13
View File
@@ -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,
},
});
}
+10 -5
View File
@@ -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',
},
});
}
+37 -69
View File
@@ -1,14 +1,11 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { PlayCircle } from 'lucide-react-native';
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
type Theme = 'light' | 'dark' | 'system';
type FontSize = 'small' | 'medium' | 'large';
type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
type ToolbarPosition = 'top' | 'below-subject';
type Theme = ThemeMode;
const DENSITY_PREVIEW: Record<Density, { py: number; gap: number; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 2, gap: 6, showAvatar: false, showPreview: false },
@@ -24,6 +21,8 @@ const PREVIEW_ROWS = [
];
function DensityPreview({ density }: { density: Density }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const cfg = DENSITY_PREVIEW[density];
return (
<View style={styles.densityPreview}>
@@ -50,7 +49,7 @@ function DensityPreview({ density }: { density: Density }) {
<Text
style={[
styles.densitySender,
row.unread ? { color: colors.text, fontWeight: '600' } : { color: colors.mutedForeground },
row.unread ? { color: c.text, fontWeight: '600' } : { color: c.mutedForeground },
]}
numberOfLines={1}
>
@@ -61,7 +60,7 @@ function DensityPreview({ density }: { density: Density }) {
<Text
style={[
styles.densitySubject,
row.unread ? { color: colors.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
row.unread ? { color: c.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
]}
numberOfLines={1}
>
@@ -80,16 +79,24 @@ function DensityPreview({ density }: { density: Density }) {
}
export function AppearanceSettings() {
const [theme, setTheme] = useState<Theme>('dark');
const [fontSize, setFontSize] = useState<FontSize>('medium');
const [density, setDensity] = useState<Density>('regular');
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition>('top');
const [showToolbarLabels, setShowToolbarLabels] = useState(true);
const [hideAccountSwitcher, setHideAccountSwitcher] = useState(false);
const [showRailAccountList, setShowRailAccountList] = useState(false);
const [colorfulSidebarIcons, setColorfulSidebarIcons] = useState(true);
const [animationsEnabled, setAnimationsEnabled] = useState(true);
const [language, setLanguage] = useState('en');
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const theme = useSettingsStore((s) => s.theme);
const setTheme = useSettingsStore((s) => s.setTheme);
const fontSize = useSettingsStore((s) => s.fontSize);
const setFontSize = useSettingsStore((s) => s.setFontSize);
const density = useSettingsStore((s) => s.density);
const setDensity = useSettingsStore((s) => s.setDensity);
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
React.useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
return (
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
@@ -105,18 +112,6 @@ export function AppearanceSettings() {
/>
</SettingItem>
<SettingItem label="Language" description="Interface language.">
<RadioGroup
value={language}
onChange={setLanguage}
options={[
{ value: 'en', label: 'EN' },
{ value: 'de', label: 'DE' },
{ value: 'fr', label: 'FR' },
]}
/>
</SettingItem>
<SettingItem label="Font Size" description="Adjust text size.">
<RadioGroup
value={fontSize}
@@ -145,64 +140,36 @@ export function AppearanceSettings() {
<View style={styles.divider} />
</View>
<SettingItem label="Toolbar Position" description="Where to place the message toolbar.">
<RadioGroup
value={toolbarPosition}
onChange={(v) => setToolbarPosition(v as ToolbarPosition)}
options={[
{ value: 'top', label: 'Top' },
{ value: 'below-subject', label: 'Below' },
]}
/>
</SettingItem>
<SettingItem label="Toolbar Labels" description="Show text labels on toolbar buttons.">
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
</SettingItem>
<SettingItem label="Hide Account Switcher" description="Hide the account switcher from the navigation rail.">
<ToggleSwitch checked={hideAccountSwitcher} onChange={setHideAccountSwitcher} />
</SettingItem>
<SettingItem label="Rail Account List" description="Show all accounts inside the navigation rail.">
<ToggleSwitch checked={showRailAccountList} onChange={setShowRailAccountList} />
</SettingItem>
<SettingItem label="Colorful Sidebar Icons" description="Display sidebar icons with their brand colors.">
<ToggleSwitch checked={colorfulSidebarIcons} onChange={setColorfulSidebarIcons} />
</SettingItem>
<SettingItem label="Animations" description="Enable or disable interface animations.">
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
</SettingItem>
<SettingItem label="Restart Tour" description="Replay the onboarding walkthrough.">
<Button variant="outline" size="sm" icon={<PlayCircle size={14} color={colors.text} />}>
Restart
</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
densityPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
backgroundColor: colors.background,
backgroundColor: c.background,
},
densityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
densityAvatar: {
borderRadius: radius.full,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
densityHeader: {
flexDirection: 'row',
@@ -216,7 +183,7 @@ const styles = StyleSheet.create({
},
densityTime: {
fontSize: 10,
color: colors.mutedForeground,
color: c.mutedForeground,
},
densitySubject: {
...typography.caption,
@@ -227,7 +194,8 @@ const styles = StyleSheet.create({
},
divider: {
height: 1,
backgroundColor: colors.border,
backgroundColor: c.border,
marginTop: spacing.md,
},
});
}
@@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { X, Plus } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
export function ComposingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const [newKeyword, setNewKeyword] = useState('');
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const addKeyword = () => {
const trimmed = newKeyword.trim().toLowerCase();
if (!trimmed || attachmentReminderKeywords.includes(trimmed)) {
setNewKeyword('');
return;
}
setAttachmentReminderKeywords([...attachmentReminderKeywords, trimmed]);
setNewKeyword('');
};
const removeKeyword = (kw: string) => {
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
};
return (
<SettingsSection
title="Composing"
description="How the composer behaves when you reply, forward, or send."
>
<SettingItem
label="Auto-select reply identity"
description="When replying, pick the identity that received the original message."
>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<SettingItem
label="Attachment reminder"
description="Warn before sending if the message mentions an attachment but none is attached."
>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={setAttachmentReminderEnabled}
/>
</SettingItem>
{attachmentReminderEnabled && (
<View style={styles.subBlock}>
<Text style={styles.subLabel}>Trigger keywords</Text>
<Text style={styles.subDescription}>
Words that, when found in the body or subject without an attachment, trigger the reminder.
</Text>
<View style={styles.chips}>
{attachmentReminderKeywords.map((kw) => (
<View key={kw} style={styles.chip}>
<Text style={styles.chipText}>{kw}</Text>
<Pressable onPress={() => removeKeyword(kw)} hitSlop={6}>
<X size={12} color={c.textSecondary} />
</Pressable>
</View>
))}
</View>
<View style={styles.addRow}>
<TextInput
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="e.g. attached"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
onSubmitEditing={addKeyword}
returnKeyType="done"
/>
<Button
variant="default"
size="sm"
onPress={addKeyword}
disabled={!newKeyword.trim()}
icon={<Plus size={14} color={c.primaryForeground} />}
>
Add
</Button>
</View>
</View>
)}
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.full,
backgroundColor: c.muted,
},
chipText: { ...typography.caption, color: c.text },
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
input: {
flex: 1,
backgroundColor: c.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
paddingHorizontal: spacing.md,
paddingVertical: 8,
color: c.text,
...typography.body,
},
});
}
+5 -3
View File
@@ -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>
);
@@ -0,0 +1,217 @@
import React, { useEffect, useState } from 'react';
import {
View, Text, StyleSheet, Pressable, TextInput, FlatList, Modal,
} from 'react-native';
import { ChevronRight, X, Plus, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
export function ContentSendersSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const setEmailAlwaysLightMode = useSettingsStore((s) => s.setEmailAlwaysLightMode);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const [modalOpen, setModalOpen] = useState(false);
const [newSender, setNewSender] = useState('');
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const trustedCount = trustedSenders.length;
const trustedLabel = trustedCount === 0 ? 'None' : trustedCount === 1 ? '1 sender' : `${trustedCount} senders`;
return (
<View style={{ gap: spacing.xxxl }}>
<SettingsSection
title="Content & Senders"
description="Control how external resources and tracking pixels are handled."
>
<SettingItem
label="External content"
description="What to do when an email links to remote images or media."
>
<RadioGroup
value={externalContentPolicy}
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
options={[
{ value: 'ask', label: 'Ask' },
{ value: 'block', label: 'Block' },
{ value: 'allow', label: 'Allow' },
]}
/>
</SettingItem>
<SettingItem
label="Always view emails in light mode"
description="Force a light background for the message body, even when the app is in dark mode."
>
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
</SettingItem>
<SettingItem
label="Trusted senders"
description="External content always loads for senders on this list."
>
<Pressable
onPress={() => setModalOpen(true)}
style={({ pressed }) => [styles.trustedButton, pressed && styles.trustedButtonPressed]}
>
<Text style={styles.trustedButtonText}>{trustedLabel}</Text>
<ChevronRight size={14} color={c.textMuted} />
</Pressable>
</SettingItem>
</SettingsSection>
<Modal
visible={modalOpen}
transparent
animationType="fade"
onRequestClose={() => setModalOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable style={styles.modalCard} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Trusted senders</Text>
<Pressable onPress={() => setModalOpen(false)} hitSlop={6}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
<View style={styles.addRow}>
<TextInput
value={newSender}
onChangeText={setNewSender}
placeholder="sender@example.com"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
style={styles.input}
onSubmitEditing={() => {
if (newSender.trim()) {
addTrustedSender(newSender);
setNewSender('');
}
}}
/>
<Button
variant="default"
size="sm"
onPress={() => {
if (newSender.trim()) {
addTrustedSender(newSender);
setNewSender('');
}
}}
disabled={!newSender.trim()}
icon={<Plus size={14} color={c.primaryForeground} />}
>
Add
</Button>
</View>
{trustedSenders.length === 0 ? (
<Text style={styles.emptyText}>No trusted senders yet.</Text>
) : (
<FlatList
data={trustedSenders}
keyExtractor={(item) => item}
style={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.rowText} numberOfLines={1}>{item}</Text>
<Pressable
onPress={() => removeTrustedSender(item)}
hitSlop={6}
style={({ pressed }) => [styles.removeBtn, pressed && styles.removeBtnPressed]}
>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
)}
/>
)}
</Pressable>
</Pressable>
</Modal>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
trustedButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: c.muted,
borderRadius: radius.sm,
},
trustedButtonPressed: { opacity: 0.7 },
trustedButtonText: { ...typography.body, color: c.text },
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
paddingHorizontal: spacing.lg,
},
modalCard: {
backgroundColor: c.card,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: c.border,
padding: spacing.lg,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
modalTitle: { ...typography.h3, color: c.text },
addRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md },
input: {
flex: 1,
backgroundColor: c.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
paddingHorizontal: spacing.md,
paddingVertical: 8,
color: c.text,
...typography.body,
},
list: { maxHeight: 300 },
separator: { height: 1, backgroundColor: c.border },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
},
rowText: { ...typography.body, color: c.text, flex: 1, marginRight: spacing.sm },
removeBtn: { padding: 6, borderRadius: radius.sm },
removeBtnPressed: { backgroundColor: c.muted },
emptyText: {
...typography.body,
color: c.textMuted,
textAlign: 'center',
paddingVertical: spacing.xl,
},
});
}
+29 -22
View File
@@ -4,7 +4,8 @@ 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';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
type FolderLayout = 'inline' | 'sidebar';
type ViewMode = 'list' | 'grid';
@@ -57,24 +58,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 +96,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 +114,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,6 +130,8 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
}
export function FilesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
@@ -188,7 +193,7 @@ export function FilesSettings() {
<SettingItem label="Show icons" description="Display icons for files and folders.">
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
</SettingItem>
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
<SettingItem label="Colored icons" description="Use file-type-specific c.">
<ToggleSwitch
checked={prefs.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
@@ -212,14 +217,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 +236,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 +247,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 +264,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' },
});
}
+20 -15
View File
@@ -3,7 +3,8 @@ 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';
interface Rule {
id: string;
@@ -16,6 +17,8 @@ interface Rule {
const SAMPLE_RULES: Rule[] = [];
export function FilterSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
const [expandedView, setExpandedView] = useState(false);
const [isOpaque] = useState(false);
@@ -45,7 +48,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 +58,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 +68,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 +91,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 +103,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>
@@ -120,7 +123,8 @@ export function FilterSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xl },
opaqueWarning: {
flexDirection: 'row',
@@ -134,11 +138,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 +150,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 +171,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 },
});
}
+16 -11
View File
@@ -4,7 +4,8 @@ import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
} 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 { useEmailStore } from '../../stores/email-store';
import type { Mailbox } from '../../api/types';
@@ -23,6 +24,8 @@ function getIcon(mb: 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);
@@ -47,7 +50,7 @@ export function FolderSettings() {
>
{mailboxes.length === 0 ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={c.primary} />
</View>
) : (
<View>
@@ -58,7 +61,7 @@ export function FolderSettings() {
<View style={styles.folderLeft}>
<Icon
size={16}
color={mb.role ? colors.primary : colors.mutedForeground}
color={mb.role ? c.primary : c.mutedForeground}
/>
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
{mb.role && ROLE_LABEL[mb.role] && (
@@ -94,7 +97,8 @@ export function FolderSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
@@ -106,22 +110,23 @@ const styles = StyleSheet.create({
borderRadius: radius.sm,
},
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: colors.text, flexShrink: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: colors.primary },
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: colors.primary,
backgroundColor: c.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 },
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
caption: { ...typography.caption, color: c.mutedForeground },
});
}
+15 -10
View File
@@ -2,10 +2,13 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { SettingsSection, SettingItem } 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';
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);
@@ -30,7 +33,7 @@ export function IdentitySettings() {
>
<View style={styles.row}>
{loading ? (
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={c.primary} />
) : (
<Text style={styles.count}>{countLabel}</Text>
)}
@@ -67,7 +70,8 @@ export function IdentitySettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -75,14 +79,14 @@ const styles = StyleSheet.create({
},
count: {
...typography.body,
color: colors.text,
color: c.text,
},
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
},
errorText: { ...typography.caption, color: colors.error },
errorText: { ...typography.caption, color: c.error },
identityRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -90,15 +94,16 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
identityName: { ...typography.bodyMedium, color: colors.text },
identityEmail: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.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 },
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
});
}
+59 -53
View File
@@ -1,45 +1,47 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
import { DARK_COLORS } from '../../theme/tokens';
interface Keyword {
id: string;
label: string;
color: keyof typeof colors.tags;
}
type Keyword = KeywordDef;
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
const DEFAULT_KEYWORDS: Keyword[] = [
{ id: 'important', label: 'Important', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'personal', label: 'Personal', color: 'green' },
{ id: 'todo', label: 'Todo', color: 'amber' },
];
// Palette keys are theme-agnostic (same names in both palettes), so use DARK_COLORS
// at module load. The actual rendered swatch colors come from the active theme via `c.tags[key]`.
const PALETTE_KEYS = Object.keys(DARK_COLORS.tags) as (keyof typeof DARK_COLORS.tags)[];
export function KeywordSettings() {
const [keywords, setKeywords] = useState<Keyword[]>(DEFAULT_KEYWORDS);
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const keywords = useKeywordsStore((s) => s.keywords);
const addKeyword = useKeywordsStore((s) => s.add);
const updateKeyword = useKeywordsStore((s) => s.update);
const removeKeyword = useKeywordsStore((s) => s.remove);
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
const hydrated = useKeywordsStore((s) => s.hydrated);
const hydrate = useKeywordsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const saveKeyword = (kw: Keyword, editing: boolean) => {
if (editing) {
setKeywords((ks) => ks.map((k) => k.id === editingId ? kw : k));
if (editing && editingId) {
const { id, ...patch } = kw;
updateKeyword(editingId, patch);
setEditingId(null);
} else {
setKeywords((ks) => [...ks, kw]);
addKeyword(kw);
setIsAdding(false);
}
};
const deleteKeyword = (id: string) => {
setKeywords((ks) => ks.filter((k) => k.id !== id));
};
const resetDefaults = () => {
setKeywords(DEFAULT_KEYWORDS);
removeKeyword(id);
};
return (
@@ -57,7 +59,7 @@ export function KeywordSettings() {
/>
);
}
const palette = colors.tags[kw.color];
const palette = c.tags[kw.color];
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
@@ -65,10 +67,10 @@ export function KeywordSettings() {
<Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
<Pencil size={14} color={colors.mutedForeground} />
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
<Trash2 size={14} color={colors.mutedForeground} />
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
</View>
@@ -86,11 +88,11 @@ export function KeywordSettings() {
{!isAdding && editingId === null && (
<View style={styles.bottomActions}>
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
<Plus size={14} color={colors.mutedForeground} />
<Plus size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>Add keyword</Text>
</Pressable>
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
<RotateCcw size={14} color={colors.mutedForeground} />
<RotateCcw size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>Reset defaults</Text>
</Pressable>
</View>
@@ -108,8 +110,10 @@ interface KeywordFormProps {
}
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [label, setLabel] = useState(initial?.label ?? '');
const [color, setColor] = useState<keyof typeof colors.tags>(initial?.color ?? 'blue');
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
const normalizedId = label
.trim()
@@ -134,7 +138,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
value={label}
onChangeText={setLabel}
placeholder="e.g. Important"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={styles.input}
maxLength={30}
autoFocus
@@ -146,7 +150,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<Text style={styles.formLabel}>Color</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = colors.tags[key];
const p = c.tags[key];
return (
<Pressable
key={key}
@@ -164,7 +168,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<View style={styles.formActions}>
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
<X size={14} color={colors.text} />
<X size={14} color={c.text} />
<Text style={styles.cancelFormText}>Cancel</Text>
</Pressable>
<Pressable
@@ -172,7 +176,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
onPress={handleSave}
disabled={!isValid}
>
<Check size={14} color={colors.primaryForeground} />
<Check size={14} color={c.primaryForeground} />
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
</Pressable>
</View>
@@ -180,7 +184,8 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
kwRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -188,12 +193,12 @@ const styles = StyleSheet.create({
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
borderColor: c.border,
backgroundColor: c.background,
},
kwDot: { width: 20, height: 20, borderRadius: 10 },
kwLabel: { ...typography.bodyMedium, color: colors.text, flex: 1 },
kwId: { fontSize: 10, color: colors.mutedForeground, fontFamily: 'monospace' },
kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 },
kwId: { fontSize: 10, color: c.mutedForeground, fontFamily: 'monospace' },
iconBtn: {
width: 28,
height: 28,
@@ -215,34 +220,34 @@ const styles = StyleSheet.create({
borderRadius: radius.sm,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: colors.border,
borderColor: c.border,
},
outlineBtnText: { ...typography.caption, color: colors.mutedForeground },
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
form: {
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.primaryBorder,
backgroundColor: colors.accent,
borderColor: c.primaryBorder,
backgroundColor: c.accent,
},
formLabel: { ...typography.caption, color: colors.mutedForeground, marginBottom: 4 },
formLabel: { ...typography.caption, color: c.mutedForeground, marginBottom: 4 },
input: {
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
borderColor: c.border,
color: c.text,
...typography.body,
},
errorText: { ...typography.caption, color: colors.error, marginTop: 4 },
errorText: { ...typography.caption, color: c.error, marginTop: 4 },
palette: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
colorSwatch: { width: 24, height: 24, borderRadius: 12 },
colorSwatchSelected: {
borderWidth: 2,
borderColor: colors.text,
borderColor: c.text,
},
formActions: {
flexDirection: 'row',
@@ -257,9 +262,9 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
cancelFormText: { ...typography.caption, color: colors.text },
cancelFormText: { ...typography.caption, color: c.text },
saveBtn: {
flexDirection: 'row',
alignItems: 'center',
@@ -267,7 +272,8 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
saveBtnText: { ...typography.caption, color: colors.primaryForeground, fontWeight: '500' },
saveBtnText: { ...typography.caption, color: c.primaryForeground, fontWeight: '500' },
});
}
@@ -0,0 +1,78 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Check } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
export function LanguageSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const override = useLocaleStore((s) => s.override);
const setOverride = useLocaleStore((s) => s.setOverride);
const hydrated = useLocaleStore((s) => s.hydrated);
const hydrate = useLocaleStore((s) => s.hydrate);
const t = useLocaleStore((s) => s.t);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const deviceCode = detectDeviceLocale();
const selected: LocaleCode | 'system' = override ?? 'system';
const renderRow = (code: LocaleCode | 'system', label: string, sublabel?: string) => {
const active = selected === code;
return (
<Pressable
key={code}
onPress={() => setOverride(code === 'system' ? null : (code as LocaleCode))}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={{ flex: 1 }}>
<Text style={styles.label}>{label}</Text>
{sublabel ? <Text style={styles.sublabel}>{sublabel}</Text> : null}
</View>
{active ? <Check size={16} color={c.primary} /> : null}
</Pressable>
);
};
const deviceLabel =
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
return (
<SettingsSection
title={t('settings.appearance.language.label', 'Language')}
description={t('settings.appearance.language.description', 'Choose your preferred language')}
>
<SettingItem
label={t('settings.appearance.language.label', 'Language')}
description={t('settings.appearance.language.description', 'Choose your preferred language')}
noBorder
/>
<View style={styles.list}>
{renderRow('system', `System default (${deviceLabel})`)}
<View style={styles.divider} />
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
</View>
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: c.background },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
rowPressed: { backgroundColor: c.muted },
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: c.text },
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
});
}
@@ -0,0 +1,94 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, RadioGroup } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'archive', label: 'Archive' },
{ value: 'delete', label: 'Delete' },
{ value: 'spam', label: 'Spam' },
{ value: 'read', label: 'Read/Unread' },
{ value: 'star', label: 'Star' },
{ value: 'pin', label: 'Pin' },
{ value: 'move', label: 'Move to folder' },
];
const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'instant', label: 'Instant (swipe = action)' },
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
<Text style={styles.rowLabel}>Swipe behavior</Text>
<Text style={styles.rowDescription}>
Pick instant (swipe past the threshold to fire the action) or reveal
(swipe to expose an action band, then tap to confirm).
</Text>
<RadioGroup
value={swipeMode}
onChange={(v) => setSwipeMode(v as SwipeMode)}
options={SWIPE_MODE_OPTIONS}
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowRight size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
</View>
<Text style={styles.rowDescription}>
Action when you drag a row from its left edge towards the right.
</Text>
<RadioGroup
value={swipeRightAction}
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
options={SWIPE_OPTIONS}
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowLeft size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
</View>
<Text style={styles.rowDescription}>
Action when you drag a row from its right edge towards the left.
</Text>
<RadioGroup
value={swipeLeftAction}
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
options={SWIPE_OPTIONS}
/>
</View>
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: c.text },
rowDescription: { ...typography.caption, color: c.mutedForeground },
});
}
@@ -10,7 +10,8 @@ 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 {
DEFAULT_RELAY_BASE_URL,
@@ -35,6 +36,8 @@ 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');
@@ -120,26 +123,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 +155,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,7 +186,7 @@ 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}
@@ -229,12 +232,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 +251,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 +270,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 },
});
}
+35 -28
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } 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';
interface Plugin {
id: string;
@@ -19,15 +20,19 @@ 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 [plugins, setPlugins] = useState<Plugin[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -52,7 +57,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 +65,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 +112,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 +150,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 +158,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 +179,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 +193,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,
},
});
}
+132 -94
View File
@@ -1,19 +1,23 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { AlertTriangle, FolderSync, Mail, 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';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ArchiveMode, type ExternalContentPolicy } from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
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'];
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,12 +70,16 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
}
export function ReadingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
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 hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const archiveMode = useSettingsStore((s) => s.archiveMode);
const setArchiveMode = useSettingsStore((s) => s.setArchiveMode);
const [markAsReadDelay, setMarkAsReadDelay] = useState('0');
const [deleteAction, setDeleteAction] = useState<DeleteAction>('trash');
@@ -85,7 +93,8 @@ export function ReadingSettings() {
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 [reorganizing, setReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true);
const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState<string[]>(DEFAULT_KEYWORDS);
const [newKeyword, setNewKeyword] = useState('');
@@ -97,6 +106,61 @@ export function ReadingSettings() {
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.">
@@ -124,7 +188,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>
)}
@@ -143,10 +207,25 @@ 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>
@@ -206,7 +285,7 @@ export function ReadingSettings() {
}
hitSlop={6}
>
<X size={12} color={colors.mutedForeground} />
<X size={12} color={c.mutedForeground} />
</Pressable>
</View>
))}
@@ -216,7 +295,7 @@ 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();
@@ -282,62 +361,12 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label="Always Light Mode for Emails" description="Render email content on a white background.">
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
</SettingItem>
<SettingItem label="External Content" description="How to handle images and remote resources in incoming email.">
<Select
value={externalContentPolicy}
onChange={(v) => setExternalContentPolicyStore(v as ExternalContentPolicy)}
options={[
{ value: 'ask', label: 'Ask' },
{ value: 'block', label: 'Block' },
{ value: 'allow', label: 'Allow' },
]}
/>
</SettingItem>
<View style={styles.group}>
<SettingItem
label="Trusted Senders"
description="Senders whose external content loads without asking."
noBorder
>
<Pressable style={styles.inlineBtn} onPress={() => setShowTrustedList((v) => !v)}>
<Text style={styles.inlineBtnText}>
{trustedSenders.length === 0
? 'No trusted senders'
: `${trustedSenders.length} sender${trustedSenders.length === 1 ? '' : 's'}`}
</Text>
</Pressable>
</SettingItem>
{showTrustedList && trustedSenders.length > 0 && (
<View style={styles.trustedList}>
{trustedSenders.map((email) => (
<View key={email} style={styles.trustedRow}>
<Text style={styles.trustedEmail} numberOfLines={1}>{email}</Text>
<Pressable onPress={() => removeTrustedSender(email)} hitSlop={8}>
<X size={14} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
)}
<View style={styles.divider} />
</View>
<SettingItem label="Sync Trusted Senders" description="Store trusted senders in the address book.">
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={setTrustedSendersAddressBook}
/>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
group: {},
warning: {
flexDirection: 'row',
@@ -349,12 +378,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: {
@@ -363,23 +392,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,
},
@@ -395,11 +432,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',
@@ -411,37 +448,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: {
@@ -450,27 +487,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)',
@@ -478,16 +515,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,
@@ -506,14 +543,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',
@@ -523,7 +560,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 },
});
}
+68 -59
View File
@@ -5,7 +5,8 @@ import {
} 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';
interface SidebarApp {
id: string;
@@ -17,6 +18,8 @@ interface SidebarApp {
}
export function SidebarAppsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [keepLoaded, setKeepLoaded] = useState(false);
const [apps, setApps] = useState<SidebarApp[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -63,9 +66,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 +80,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 +104,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 +121,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 +138,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 +149,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 +163,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 +172,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 +199,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 +215,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 +250,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 },
});
}
+33 -28
View File
@@ -6,7 +6,8 @@ import {
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch, Select } 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';
interface KeyRecord {
id: string;
@@ -31,6 +32,8 @@ const MOCK_KEYS: KeyRecord[] = [];
const MOCK_CERTS: PublicCert[] = [];
export function SmimeSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
const [bindings, setBindings] = useState<Record<string, string>>({});
@@ -64,9 +67,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 +83,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 +108,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 +119,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 +153,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>
@@ -185,7 +188,8 @@ export function SmimeSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
certRow: {
flexDirection: 'row',
@@ -195,7 +199,7 @@ const styles = StyleSheet.create({
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
certLeft: {
flexDirection: 'row',
@@ -211,12 +215,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 +231,9 @@ const styles = StyleSheet.create({
},
empty: {
...typography.body,
color: colors.mutedForeground,
color: c.mutedForeground,
textAlign: 'center',
paddingVertical: spacing.lg,
},
});
}
+11 -6
View File
@@ -3,9 +3,12 @@ import { View, Text, StyleSheet } from 'react-native';
import { FileText, Download, Upload } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { colors, spacing, typography } from '../../theme/tokens';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export function TemplateSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [templates] = useState<{ id: string; name: string }[]>([]);
return (
@@ -13,7 +16,7 @@ export function TemplateSettings() {
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
<View style={styles.row}>
<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>
@@ -28,11 +31,11 @@ export function TemplateSettings() {
variant="outline"
size="sm"
disabled={templates.length === 0}
icon={<Download size={14} color={colors.text} />}
icon={<Download size={14} color={c.text} />}
>
Export
</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} />}>
Import
</Button>
</View>
@@ -41,7 +44,8 @@ export function TemplateSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
row: {
flexDirection: 'row',
@@ -50,6 +54,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: colors.mutedForeground },
count: { ...typography.body, color: c.mutedForeground },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
});
}
+18 -13
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Check, Palette, Trash2 } 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';
interface Theme {
id: string | null;
@@ -20,6 +21,8 @@ const BUILT_IN: Theme[] = [
];
export function ThemesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [themes] = useState<Theme[]>(BUILT_IN);
const [active, setActive] = useState<string | null>(null);
@@ -40,12 +43,12 @@ export function ThemesSettings() {
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 +70,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 +78,8 @@ export function ThemesSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -87,19 +91,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 +112,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 },
});
}
+127 -20
View File
@@ -1,9 +1,11 @@
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';
function formatTime(ts: number): string {
if (!ts) return 'never';
@@ -11,11 +13,38 @@ function formatTime(ts: number): string {
return date.toLocaleString();
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function formatProgressLabel(p: InstallProgress): string {
const pct = p.progress != null ? `${Math.round(p.progress * 100)}%` : null;
const written = p.bytesWritten ? formatBytes(p.bytesWritten) : null;
const total = p.totalBytes ? formatBytes(p.totalBytes) : null;
const sizes = written && total ? `${written} of ${total}` : written ?? '';
return [pct, sizes].filter(Boolean).join(' · ') || 'Downloading…';
}
function installButtonLabel(
installing: boolean,
progress: InstallProgress | null,
): string {
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);
const installing = useUpdatesStore((s) => s.installing);
const installProgress = useUpdatesStore((s) => s.installProgress);
const error = useUpdatesStore((s) => s.error);
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
@@ -32,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();
};
@@ -69,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}
>
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
{installButtonLabel(installing, installProgress)}
</Button>
) : (
<Button
@@ -95,6 +146,44 @@ 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}>
<View
style={[
styles.progressFill,
{
width:
installProgress.phase === 'installing'
? '100%'
: `${Math.round((installProgress.progress ?? 0) * 100)}%`,
},
]}
/>
</View>
<Text style={styles.progressLabel}>
{installProgress.phase === 'installing'
? 'Opening installer…'
: formatProgressLabel(installProgress)}
</Text>
</View>
) : null}
{cachedLatest?.body ? (
<View style={styles.notesBox}>
<Text style={styles.notesTitle}>Release notes</Text>
@@ -108,17 +197,35 @@ 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,
gap: 6,
},
progressTrack: {
height: 6,
borderRadius: 3,
backgroundColor: c.muted,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: c.primary,
},
progressLabel: { ...typography.caption, color: c.mutedForeground },
});
}
+38 -33
View File
@@ -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' },
});
}
+33 -22
View File
@@ -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,
},
});
});
}
+71
View File
@@ -0,0 +1,71 @@
// Minimal i18n: lookup keys like `settings.tabs.account` against a flat
// dictionary loaded from ./locales/<lang>/common.json. Picks language from
// the user override (locale-store) → device locale → English fallback.
import { getLocales } from 'expo-localization';
import en from '../../locales/en/common.json';
import de from '../../locales/de/common.json';
import cs from '../../locales/cs/common.json';
import es from '../../locales/es/common.json';
import fr from '../../locales/fr/common.json';
import it from '../../locales/it/common.json';
import ja from '../../locales/ja/common.json';
import ko from '../../locales/ko/common.json';
import lv from '../../locales/lv/common.json';
import nl from '../../locales/nl/common.json';
import pl from '../../locales/pl/common.json';
import pt from '../../locales/pt/common.json';
import ru from '../../locales/ru/common.json';
import uk from '../../locales/uk/common.json';
import zh from '../../locales/zh/common.json';
export const SUPPORTED_LOCALES = [
{ code: 'en', label: 'English' },
{ code: 'cs', label: 'Čeština' },
{ code: 'de', label: 'Deutsch' },
{ code: 'es', label: 'Español' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'pl', label: 'Polski' },
{ code: 'pt', label: 'Português' },
{ code: 'ru', label: 'Русский' },
{ code: 'uk', label: 'Українська' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'zh', label: '中文' },
{ code: 'lv', label: 'Latviešu' },
] as const;
export type LocaleCode = typeof SUPPORTED_LOCALES[number]['code'];
const dictionaries: Record<LocaleCode, Record<string, unknown>> = {
en, cs, de, es, fr, it, nl, pl, pt, ru, uk, ja, ko, zh, lv,
};
export function detectDeviceLocale(): LocaleCode {
const codes = SUPPORTED_LOCALES.map((l) => l.code) as readonly string[];
for (const l of getLocales()) {
const lang = l.languageCode?.toLowerCase();
if (lang && codes.includes(lang)) return lang as LocaleCode;
}
return 'en';
}
function lookup(dict: Record<string, unknown>, key: string): string | undefined {
let current: unknown = dict;
for (const part of key.split('.')) {
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof current === 'string' ? current : undefined;
}
export function translate(locale: LocaleCode, key: string, fallback?: string): string {
return lookup(dictionaries[locale], key)
?? lookup(dictionaries.en, key)
?? fallback
?? key;
}
+124
View File
@@ -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 &lt;a@b.com&gt; 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 &lt;b@c.com&gt;');
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('&lt;script&gt;x&lt;/script&gt;');
});
});
describe('htmlToPlainText', () => {
it('returns empty string for empty input', () => {
expect(htmlToPlainText('')).toBe('');
});
it('strips tags and decodes entities', () => {
expect(htmlToPlainText('<p>hello &amp; 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 &nbsp; to space', () => {
expect(htmlToPlainText('hello&nbsp;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']);
});
});
+20
View File
@@ -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,
+134
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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) };
}
+54
View File
@@ -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;
+42 -3
View File
@@ -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,11 +7,49 @@ import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as { authHeader?: string }).authHeader;
const h = (jmapClient as unknown as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
}
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,
});
}
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
const r = await fetch(url, { headers: { Authorization: authHeader() } });
@@ -25,15 +64,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',
+55 -3
View File
@@ -7,16 +7,55 @@ import type { ReleaseAsset } from '../api/updates';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void> {
export interface InstallProgress {
phase: 'downloading' | 'installing';
/** 0-1 during download; undefined for installing phase */
progress?: number;
/** Bytes received during current download */
bytesWritten?: number;
/** Total bytes for the download (best-effort, may be 0 if server omits Content-Length) */
totalBytes?: number;
}
export type InstallProgressListener = (p: InstallProgress) => void;
export async function downloadAndInstallApk(
asset: ReleaseAsset,
onProgress?: InstallProgressListener,
): Promise<void> {
if (Platform.OS !== 'android') {
throw new Error('APK install is only supported on Android');
}
const dest = new File(Paths.cache, asset.name);
if (dest.exists) dest.delete();
const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest);
const contentUri = await FileSystemLegacy.getContentUriAsync(downloaded.uri);
onProgress?.({ phase: 'downloading', progress: 0 });
const download = FileSystemLegacy.createDownloadResumable(
asset.browser_download_url,
dest.uri,
{},
(state) => {
const total = state.totalBytesExpectedToWrite ?? 0;
const progress = total > 0 ? state.totalBytesWritten / total : undefined;
onProgress?.({
phase: 'downloading',
progress,
bytesWritten: state.totalBytesWritten,
totalBytes: total,
});
},
);
const result = await download.downloadAsync();
if (!result?.uri) {
throw new Error('APK download failed');
}
onProgress?.({ phase: 'installing' });
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
@@ -24,3 +63,16 @@ export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void>
flags: FLAG_GRANT_READ_URI_PERMISSION | FLAG_ACTIVITY_NEW_TASK,
});
}
/**
* Open the system "Install unknown apps" settings page for this app, so the
* user can grant permission to install APKs from the package installer.
* Useful as a fallback if the install intent never reaches the installer.
*/
export async function openInstallUnknownAppsSettings(): Promise<void> {
if (Platform.OS !== 'android') return;
await IntentLauncher.startActivityAsync(
'android.settings.MANAGE_UNKNOWN_APP_SOURCES',
{ data: 'package:com.anonymous.bulwarkmobile' },
);
}
+68 -5
View File
@@ -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
+76
View File
@@ -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 -24
View File
@@ -31,7 +31,8 @@ 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 { MonthView } from '../components/calendar/MonthView';
@@ -92,6 +93,8 @@ function headerTitle(viewMode: ViewMode, currentDate: Date): string {
}
export default function CalendarScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
@@ -261,7 +264,7 @@ 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>
@@ -281,26 +284,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>
@@ -350,7 +353,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 +418,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 +429,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 +442,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 +457,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 +476,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 +492,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 +518,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 +538,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 +547,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 },
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43 -38
View File
@@ -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 -106
View File
@@ -6,10 +6,17 @@ 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 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';
function getSenderName(email: Email): string {
@@ -28,6 +35,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();
@@ -56,6 +67,8 @@ const EmailRow = React.memo(function EmailRow({
selected: boolean;
selectionMode: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -78,9 +91,9 @@ 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>
)}
@@ -95,10 +108,10 @@ const EmailRow = React.memo(function EmailRow({
{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}>
@@ -122,7 +135,11 @@ const EmailRow = React.memo(function EmailRow({
);
});
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;
@@ -132,6 +149,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);
@@ -151,7 +170,31 @@ 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 networkOnline = useNetworkStore((s) => s.online);
const archiveMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
[mailboxes],
);
const trashMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
[mailboxes],
);
const junkMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
[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());
@@ -187,17 +230,65 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}
}, [toggleSelect]);
const handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => {
if (action === 'none') return;
const email = emailsRef.current.find((e) => e.id === id);
if (!email || !currentMailboxId) return;
switch (action) {
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
void archiveEmailAction(id);
}
break;
case 'delete':
if (trashMailboxId) {
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
}
break;
case 'spam':
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
}
break;
case 'read':
if (isUnread(email)) void markRead(id);
else void markUnread(id);
break;
case 'star':
void toggleStar(id, !isStarred(email));
break;
case 'pin':
void togglePin(id, !isPinned(email));
break;
case 'move':
setPendingMoveId(id);
break;
}
}, [
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
markRead, markUnread, toggleStar, togglePin,
]);
const renderEmailRow = React.useCallback(
({ item }: { item: Email }) => (
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={handleRowPress}
onLongPress={toggleSelect}
/>
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
mode={swipeMode}
context={{ unread: isUnread(item), starred: isStarred(item), pinned: isPinned(item) }}
onAction={(action) => handleSwipeAction(item.id, action)}
>
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={handleRowPress}
onLongPress={toggleSelect}
/>
</SwipeableRow>
),
[selectedIds, selectionMode, handleRowPress, toggleSelect],
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction],
);
const clearSelection = React.useCallback(() => {
@@ -323,7 +414,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
@@ -333,8 +424,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
@@ -343,9 +434,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
@@ -353,13 +444,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 }} />
@@ -375,21 +466,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"
@@ -402,7 +493,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>
@@ -410,7 +501,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>
@@ -423,7 +514,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>
@@ -446,35 +537,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>
@@ -493,15 +584,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>
@@ -543,7 +638,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)} />
@@ -566,7 +661,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
@@ -574,7 +669,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>
@@ -587,7 +682,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}
@@ -599,7 +694,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}
@@ -613,7 +708,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>
@@ -625,7 +720,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>
@@ -634,7 +729,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>
@@ -645,7 +740,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>
@@ -654,7 +749,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>
@@ -663,13 +758,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')}
@@ -677,9 +772,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'}
@@ -734,6 +829,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>
);
}
@@ -749,6 +860,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 (
@@ -774,8 +887,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: {
@@ -784,7 +898,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
height: componentSizes.headerHeight,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
gap: spacing.md,
},
headerButton: {
@@ -792,7 +906,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,
@@ -817,13 +931,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',
@@ -831,7 +945,7 @@ const styles = StyleSheet.create({
position: 'relative',
},
filterButtonActive: {
backgroundColor: colors.accent,
backgroundColor: c.accent,
},
filterBadge: {
position: 'absolute',
@@ -840,7 +954,7 @@ const styles = StyleSheet.create({
minWidth: 16,
height: 16,
borderRadius: 8,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
@@ -848,7 +962,7 @@ const styles = StyleSheet.create({
filterBadgeText: {
fontSize: 10,
fontWeight: '700',
color: colors.primaryForeground,
color: c.primaryForeground,
lineHeight: 14,
},
searchClearButton: {
@@ -870,9 +984,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,
@@ -880,7 +994,7 @@ const styles = StyleSheet.create({
},
chipText: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
clearAllButton: {
paddingHorizontal: spacing.sm,
@@ -888,7 +1002,7 @@ const styles = StyleSheet.create({
},
clearAllText: {
...typography.captionMedium,
color: colors.primary,
color: c.primary,
},
filterBackdrop: {
flex: 1,
@@ -901,10 +1015,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',
@@ -914,11 +1028,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',
@@ -935,7 +1049,7 @@ const styles = StyleSheet.create({
},
filterMenuHeaderBtnText: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
filterMenuClose: {
width: 28,
@@ -955,15 +1069,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,
@@ -973,20 +1087,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',
@@ -1003,26 +1117,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: {
@@ -1034,12 +1148,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',
@@ -1047,11 +1161,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
@@ -1062,10 +1176,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,
@@ -1077,7 +1191,7 @@ const styles = StyleSheet.create({
width: 18,
height: 18,
borderRadius: radius.xs,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
@@ -1096,7 +1210,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',
@@ -1107,20 +1221,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: {
@@ -1147,7 +1261,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,
@@ -1168,26 +1282,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 };
+16 -11
View File
@@ -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,
},
});
});
}

Some files were not shown because too many files have changed in this diff Show More