Compare commits

...
26 Commits
Author SHA1 Message Date
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
Linus Rath 892ff33f14 chore: update version number to 0.1.5 2026-04-26 21:38:21 +02:00
Linus Rath 7e42cb85ea feat: add EmailSourceScreen for viewing and sharing email source, update navigation and types 2026-04-26 21:38:07 +02:00
Linus Rath 02c590beb3 chore: update version to 0.1.4 in VERSION, package.json, and package-lock.json 2026-04-26 21:20:18 +02:00
Linus Rath e5bbfeb26e refactor: optimize EmailRow component and improve row press handling in EmailListScreen 2026-04-26 21:19:08 +02:00
Linus Rath 373c66f2ba feat: implement navigation between emails in EmailThreadScreen with previous and next buttons 2026-04-26 20:55:29 +02:00
Linus Rath 07a18595aa Refactor SettingsScreen: Consolidate settings tabs, improve layout, and enhance user experience 2026-04-26 20:49:17 +02:00
Linus Rath 2309845274 feat: add AdvancedSettings and EmailSettings components for enhanced user configuration 2026-04-26 20:49:06 +02:00
Linus Rath 04815c4e60 feat: add back button handling in SettingsScreen to manage tab selection 2026-04-26 20:31:44 +02:00
Linus Rath fbfb887e0a chore: update version number to 0.1.3 2026-04-26 20:28:29 +02:00
Linus Rath 10db4c503a feat: update APK installation process to use expo-intent-launcher and improve versioning 2026-04-26 20:27:30 +02:00
Linus Rath abbda7a74d chore: update version number to 0.1.2 2026-04-26 17:42:49 +02:00
Linus Rath fe7d9bdf09 feat: add more actions and move functionality to EmailThreadScreen 2026-04-26 17:40:53 +02:00
Linus Rath a8a0d475db chore: update readme 2026-04-26 04:57:10 +02:00
Linus Rath dd2f941dbb chore: update version number to 0.1.1 2026-04-26 04:53:20 +02:00
Linus Rath 2fa48e8e05 feat: enhance Android release workflow with caching and tag resolution 2026-04-26 04:53:01 +02:00
Linus Rath b274d4b76b feat: implement update management system with update banner and settings 2026-04-26 04:50:03 +02:00
Linus Rath d5710128fd chore: ensure gradlew is executable before building release APK 2026-04-26 04:19:05 +02:00
Linus Rath 96bddad289 chore: update gradlew file permissions to executable 2026-04-26 04:18:58 +02:00
82 changed files with 45151 additions and 2572 deletions
+56 -3
View File
@@ -28,9 +28,41 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- uses: android-actions/setup-android@v3
- name: Cache Android NDK / CMake
uses: actions/cache@v4
with:
path: |
~/.android/build-cache
/usr/local/lib/android/sdk/ndk
/usr/local/lib/android/sdk/cmake
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:
path: |
android/app/.cxx
android/app/build/intermediates/cxx
android/app/build/intermediates/merged_native_libs
android/app/build/intermediates/stripped_native_libs
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
run: npm ci
@@ -54,7 +86,9 @@ jobs:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
run: |
chmod +x ./gradlew
./gradlew assembleRelease --no-daemon
- name: Rename APK with version
id: artifact
@@ -72,9 +106,28 @@ jobs:
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
- name: Resolve target release tag
id: target_tag
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
TAG="${{ github.event.inputs.attach_to_release_tag }}"
else
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then
echo "Will attach APK to release: $TAG"
else
echo "No release tag resolved - APK is in workflow artifacts only."
fi
- name: Attach APK to release
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.attach_to_release_tag != '')
if: steps.target_tag.outputs.tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.attach_to_release_tag }}
tag_name: ${{ steps.target_tag.outputs.tag }}
files: ${{ steps.artifact.outputs.path }}
+26 -4
View File
@@ -19,21 +19,25 @@ import {
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreenNew';
import ContactsScreen from './src/screens/ContactsScreenNew';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsScreen from './src/screens/ContactsScreen';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreenNew2';
import SettingsScreen from './src/screens/SettingsScreen';
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { colors, spacing, typography } from './src/theme/tokens';
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -64,6 +68,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<UpdateBanner />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -147,6 +153,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
{() => <SettingsScreen onLogout={logout} />}
</Tab.Screen>
</Tab.Navigator>
</View>
);
}
@@ -169,10 +176,24 @@ export default function App() {
React.useEffect(() => {
void useSettingsStore.getState().hydrate();
void useLocaleStore.getState().hydrate();
}, []);
React.useEffect(() => {
let cancelled = false;
void (async () => {
const store = useUpdatesStore.getState();
await store.hydrate();
if (cancelled) return;
if (useUpdatesStore.getState().autoCheck) {
await useUpdatesStore.getState().checkNow();
}
})();
return () => { cancelled = true; };
}, []);
// Listen for FCM messages so the app can refresh state even when woken
// from the background — the FirebaseMessagingService also posts the system
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => {
@@ -307,6 +328,7 @@ export default function App() {
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
-220
View File
@@ -1,220 +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.
+47
View File
@@ -0,0 +1,47 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
# Bulwark Mobile
> **Beta - work in progress.** Many features are unfinished or rough. Expect bugs, missing functionality, and breaking changes between releases. Do not rely on this for primary email yet.
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
## What works today
- Sign in to any JMAP server (e.g. Stalwart)
- Multiple accounts
- Email list, threads, compose
- Calendar (basic)
- Contacts (basic)
- Push notifications via FCM relay
- In-app sideload updates from GitHub Releases
## What's missing or rough
- iOS build is untested
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
## Run locally
```bash
npm install
npx expo start
```
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.1.8
+1 -1
View File
@@ -166,7 +166,7 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
// Firebase Cloud Messaging — delivers pushes via Google Play services.
// Firebase Cloud Messaging - delivers pushes via Google Play services.
// Requires android/app/google-services.json at build time.
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
+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"/>
@@ -26,7 +26,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
val data = message.data
// Hand off to JS via a headless task only when the app isn't already
// running in the foreground — HeadlessJsTaskContext throws if started
// running in the foreground - HeadlessJsTaskContext throws if started
// while foreground. When the app is open, the main JS instance already
// receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state.
+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
Vendored Regular → Executable
View File
+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
+33 -2
View File
@@ -1,12 +1,13 @@
{
"name": "bulwark-mobile",
"version": "1.0.0",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "1.0.0",
"version": "0.1.8",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
@@ -17,6 +18,8 @@
"expo": "~54.0.33",
"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",
@@ -5234,6 +5237,28 @@
"expo": "*"
}
},
"node_modules/expo-intent-launcher": {
"version": "13.0.8",
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz",
"integrity": "sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A==",
"license": "MIT",
"peerDependencies": {
"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",
@@ -8778,6 +8803,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",
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.0",
"version": "0.1.8",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -9,7 +9,9 @@
"ios": "expo run:ios",
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -21,6 +23,8 @@
"expo": "~54.0.33",
"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",
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const versionFile = path.join(root, 'VERSION');
const pkgFile = path.join(root, 'package.json');
const lockFile = path.join(root, 'package-lock.json');
const git = (...args) =>
execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
const gitInherit = (...args) =>
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
const dirty = git('status', '--porcelain');
if (dirty) {
console.error('Working tree is not clean. Commit or stash changes before bumping.');
console.error(dirty);
process.exit(1);
}
const current = fs.readFileSync(versionFile, 'utf8').trim();
const parts = current.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
console.error(`Invalid version in VERSION: "${current}" (expected MAJOR.MINOR.PATCH)`);
process.exit(1);
}
parts[2] += 1;
const next = parts.join('.');
const existingTags = new Set(git('tag', '--list').split(/\r?\n/).filter(Boolean));
if (existingTags.has(next)) {
console.error(`Tag ${next} already exists.`);
process.exit(1);
}
fs.writeFileSync(versionFile, `${next}\n`);
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
pkg.version = next;
fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
lock.version = next;
if (lock.packages?.['']) lock.packages[''].version = next;
fs.writeFileSync(lockFile, `${JSON.stringify(lock, null, 2)}\n`);
console.log(`${current} -> ${next}`);
gitInherit('add', 'VERSION', 'package.json', 'package-lock.json');
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}`);
+4 -4
View File
@@ -11,7 +11,7 @@ import { generateAccountId } from '../lib/account-utils';
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
// SecureStore keys: letters, digits, ".", "-", "_" only — no "@" or "/".
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
@@ -30,7 +30,7 @@ export class JMAPClient {
get accountId(): string {
if (!this._accountId) {
throw new Error('Not authenticated — call connect() first');
throw new Error('Not authenticated - call connect() first');
}
return this._accountId;
}
@@ -89,7 +89,7 @@ export class JMAPClient {
return this.session;
}
// Legacy single-slot restore — kept for backward-compat tests. New code
// Legacy single-slot restore - kept for backward-compat tests. New code
// should use loadAccount(accountId) driven by the account registry.
async restoreSession(): Promise<boolean> {
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY);
@@ -294,7 +294,7 @@ export class JMAPClient {
// Expand the RFC 6570 level-1 template the JMAP server advertises.
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
if (!this.session?.downloadUrl) {
throw new Error('Download URL not available — not connected');
throw new Error('Download URL not available - not connected');
}
return this.session.downloadUrl
.replace('{accountId}', encodeURIComponent(this.accountId))
+1 -1
View File
@@ -55,7 +55,7 @@ export async function createPushSubscription(params: {
}
/**
* Send the verification code back to the server — the call that flips the
* Send the verification code back to the server - the call that flips the
* subscription from pending to active (RFC 8620 §7.2.2).
*/
export async function verifyPushSubscription(
+43
View File
@@ -0,0 +1,43 @@
export const UPDATE_REPO = 'bulwarkmail/native';
export interface ReleaseAsset {
name: string;
browser_download_url: string;
size: number;
}
export interface LatestRelease {
tag: string;
name: string;
htmlUrl: string;
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`GitHub API ${res.status}`);
}
const json = (await res.json()) as {
tag_name: string;
name: string;
html_url: string;
published_at: string;
body: string;
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
return {
tag: json.tag_name,
name: json.name || json.tag_name,
htmlUrl: json.html_url,
publishedAt: json.published_at,
body: json.body || '',
apkAsset,
};
}
+1 -1
View File
@@ -521,7 +521,7 @@ const styles = StyleSheet.create({
accountName: { ...typography.bodyMedium, color: colors.text },
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
// Account menu — floating popover card under the header
// Account menu - floating popover card under the header
accountMenu: {
marginHorizontal: spacing.sm,
marginTop: spacing.xs,
+145
View File
@@ -0,0 +1,145 @@
import React, { useRef } from 'react';
import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
type LucideIcon,
} from 'lucide-react-native';
import { colors, typography } from '../theme/tokens';
import type { SwipeAction } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
/** Right-to-left swipe action (revealed under a leftward drag). */
leftAction: SwipeAction;
/** Left-to-right swipe action (revealed under a rightward drag). */
rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean };
onAction: (action: SwipeAction) => void;
}
const ACTIVATION_THRESHOLD = 0.4; // fraction of row width
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor to claim the gesture
const MIN_DX_TO_CLAIM = 6;
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' },
};
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string {
if (action === 'read') return context.unread ? 'Read' : 'Unread';
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
if (action === 'none') return '';
return ACTION_META[action].defaultLabel;
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction,
}: SwipeableRowProps) {
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const max = widthRef.current;
const clamped = Math.max(-max, Math.min(max, g.dx));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const threshold = widthRef.current * ACTIVATION_THRESHOLD;
const action = g.dx > 0 ? rightAction : leftAction;
const triggered = Math.abs(g.dx) > threshold && action !== 'none';
if (triggered) {
Animated.timing(dx, {
toValue: g.dx > 0 ? widthRef.current : -widthRef.current,
duration: 160,
useNativeDriver: true,
}).start(() => {
onAction(action);
dx.setValue(0);
});
} else {
Animated.spring(dx, {
toValue: 0,
useNativeDriver: true,
speed: 24,
bounciness: 4,
}).start();
}
claimed.current = false;
},
onPanResponderTerminate: () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<View
style={[
styles.band,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg },
]}
pointerEvents="none"
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</View>
);
};
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderBand(rightAction, 'left')}
{renderBand(leftAction, 'right')}
<Animated.View style={{ transform: [{ translateX: dx }] }}>
{children}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
wrap: { position: 'relative' },
band: {
position: 'absolute',
top: 0,
bottom: 0,
width: '100%',
justifyContent: 'center',
paddingHorizontal: 24,
},
bandInner: { flexDirection: 'row', alignItems: 'center', gap: 8 },
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
});
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Download, X } from 'lucide-react-native';
import { useUpdatesStore } from '../stores/updates-store';
import { colors, spacing, radius, typography } from '../theme/tokens';
export function UpdateBanner(): React.ReactElement | null {
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const installing = useUpdatesStore((s) => s.installing);
const installLatest = useUpdatesStore((s) => s.installLatest);
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
if (!hasUpdate()) return null;
if (cachedLatest && dismissedTag === cachedLatest.tag) return null;
if (!cachedLatest?.apkAsset) return null;
return (
<View style={styles.banner}>
<Download size={16} color={colors.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.subtitle}>
v{cachedLatest.tag} is ready to install.
</Text>
</View>
<Pressable
style={styles.installButton}
onPress={() => void installLatest()}
disabled={installing}
>
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
</Pressable>
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
<X size={14} color={colors.primaryForeground} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.primary,
},
title: { ...typography.bodyMedium, color: colors.primaryForeground },
subtitle: { ...typography.caption, color: colors.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 },
dismiss: { padding: 4 },
});
+1 -1
View File
@@ -56,7 +56,7 @@ export function AgendaView({
day.setDate(start.getDate() + i);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
// All-day events first within a day, then by start time, then title
// (stable tie-break) — mirrors the comparator the webmail uses for
// (stable tie-break) - mirrors the comparator the webmail uses for
// packing week segments and keeps multi-event days predictable.
if (a.showWithoutTime !== b.showWithoutTime) {
return a.showWithoutTime ? -1 : 1;
+3 -1
View File
@@ -32,7 +32,7 @@ interface MonthViewProps {
onLongPressDate?: (date: Date) => void;
}
export function MonthView({
function MonthViewInner({
currentDate,
selectedDate,
events,
@@ -118,6 +118,8 @@ export function MonthView({
);
}
export const MonthView = React.memo(MonthViewInner);
const styles = StyleSheet.create({
grid: { paddingHorizontal: spacing.sm },
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
+5 -3
View File
@@ -39,7 +39,7 @@ interface WeekViewProps {
weekStartsOn?: 0 | 1;
}
export function WeekView({
function WeekViewInner({
selectedDate,
events,
calendars,
@@ -84,7 +84,7 @@ export function WeekView({
? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs
: 0;
// Timed grid excludes events that fill the full day on that day — they're
// Timed grid excludes events that fill the full day on that day - they're
// already promoted to the all-day strip above.
const layoutsByDay = React.useMemo(() => {
return weekDays.map((day) => {
@@ -170,7 +170,7 @@ export function WeekView({
{weekDays.map((day) => (
<View key={day.toISOString()} style={styles.allDayCol} />
))}
{/* Segments overlay — multi-day events span their date range */}
{/* Segments overlay - multi-day events span their date range */}
<View style={styles.allDayOverlay} pointerEvents="box-none">
{allDaySegments.map((segment) => {
const color = getEventColor(segment.event, calendars);
@@ -303,6 +303,8 @@ export function WeekView({
);
}
export const WeekView = React.memo(WeekViewInner);
function minutesToTimeLabel(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
@@ -17,7 +17,7 @@ const DEBUG_CATEGORIES = [
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
];
export function AdvancedSettings() {
export function AboutDataSettings() {
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
@@ -14,7 +14,7 @@ interface Session {
}
const MOCK_SESSIONS: Session[] = [
{ id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true },
{ id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true },
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
];
+20 -59
View File
@@ -1,14 +1,10 @@
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 { 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 },
@@ -80,16 +76,22 @@ 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 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 +107,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,42 +135,13 @@ 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>
);
}
@@ -0,0 +1,132 @@
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 { colors, spacing, radius, typography } from '../../theme/tokens';
import { useSettingsStore } from '../../stores/settings-store';
export function ComposingSettings() {
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={colors.textSecondary} />
</Pressable>
</View>
))}
</View>
<View style={styles.addRow}>
<TextInput
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="e.g. attached"
placeholderTextColor={colors.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={colors.primaryForeground} />}
>
Add
</Button>
</View>
</View>
)}
</SettingsSection>
);
}
const styles = StyleSheet.create({
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: colors.text },
subDescription: { ...typography.caption, color: colors.mutedForeground },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.full,
backgroundColor: colors.muted,
},
chipText: { ...typography.caption, color: colors.text },
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
input: {
flex: 1,
backgroundColor: colors.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: 8,
color: colors.text,
...typography.body,
},
});
@@ -0,0 +1,212 @@
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 { colors, spacing, radius, typography } from '../../theme/tokens';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
export function ContentSendersSettings() {
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={colors.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={colors.textSecondary} />
</Pressable>
</View>
<View style={styles.addRow}>
<TextInput
value={newSender}
onChangeText={setNewSender}
placeholder="sender@example.com"
placeholderTextColor={colors.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={colors.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={colors.error} />
</Pressable>
</View>
)}
/>
)}
</Pressable>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
trustedButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: colors.muted,
borderRadius: radius.sm,
},
trustedButtonPressed: { opacity: 0.7 },
trustedButtonText: { ...typography.body, color: colors.text },
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
paddingHorizontal: spacing.lg,
},
modalCard: {
backgroundColor: colors.card,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.lg,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
modalTitle: { ...typography.h3, color: colors.text },
addRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md },
input: {
flex: 1,
backgroundColor: colors.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: 8,
color: colors.text,
...typography.body,
},
list: { maxHeight: 300 },
separator: { height: 1, backgroundColor: colors.border },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
},
rowText: { ...typography.body, color: colors.text, flex: 1, marginRight: spacing.sm },
removeBtn: { padding: 6, borderRadius: radius.sm },
removeBtnPressed: { backgroundColor: colors.muted },
emptyText: {
...typography.body,
color: colors.textMuted,
textAlign: 'center',
paddingVertical: spacing.xl,
},
});
+18 -22
View File
@@ -1,45 +1,41 @@
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 { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
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' },
];
export function KeywordSettings() {
const [keywords, setKeywords] = useState<Keyword[]>(DEFAULT_KEYWORDS);
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 (
@@ -0,0 +1,73 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Check } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
export function LanguageSettings() {
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={colors.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>
);
}
const styles = StyleSheet.create({
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: colors.background },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
rowPressed: { backgroundColor: colors.muted },
divider: { height: 1, backgroundColor: colors.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: colors.text },
sublabel: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
});
@@ -0,0 +1,71 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { colors, spacing, typography } from '../../theme/tokens';
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
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' },
];
export function LayoutSettings() {
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 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 }}>
<View style={styles.row}>
<ArrowRight size={14} color={colors.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={colors.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>
<SettingItem
label=""
description="Drag past about half a row to commit; lift earlier to cancel."
/>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: colors.text },
rowDescription: { ...typography.caption, color: colors.mutedForeground },
});
@@ -112,7 +112,7 @@ export function NotificationSettings() {
<View style={styles.container}>
<SettingsSection
title="Push Notifications"
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing — never mail content."
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing - never mail content."
>
<View style={styles.pushCard}>
<View style={styles.pushHeader}>
@@ -65,7 +65,7 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
);
}
export function EmailSettings() {
export function ReadingSettings() {
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
@@ -282,57 +282,6 @@ export function EmailSettings() {
/>
</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>
);
}
+190
View File
@@ -0,0 +1,190 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useUpdatesStore } from '../../stores/updates-store';
import type { InstallProgress } from '../../lib/install-update';
function formatTime(ts: number): string {
if (!ts) return 'never';
const date = new Date(ts);
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,
apkAsset: unknown,
): string {
if (!installing) return apkAsset ? 'Install' : 'Open release';
if (progress?.phase === 'installing') return 'Installing…';
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
return 'Downloading…';
}
export function UpdatesSettings() {
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);
const autoCheck = useUpdatesStore((s) => s.autoCheck);
const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck);
const checkNow = useUpdatesStore((s) => s.checkNow);
const installLatest = useUpdatesStore((s) => s.installLatest);
const currentVersion = useUpdatesStore((s) => s.currentVersion);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const updateAvailable = hasUpdate();
const apkAsset = cachedLatest?.apkAsset ?? null;
const onInstall = () => {
if (!apkAsset) {
if (cachedLatest?.htmlUrl) {
void Linking.openURL(cachedLatest.htmlUrl);
}
return;
}
void installLatest();
};
return (
<View style={styles.container}>
<SettingsSection title="App updates" description="Check for new versions and install them.">
<SettingItem label="Current version">
<Text style={styles.value}>v{currentVersion()}</Text>
</SettingItem>
<SettingItem
label="Latest version"
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
>
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '-'}</Text>
</SettingItem>
<SettingItem
label="Last checked"
description={error ? `Last error: ${error}` : undefined}
>
<Text style={styles.value}>{formatTime(lastCheckedAt)}</Text>
</SettingItem>
<SettingItem label="Check automatically" description="Check for updates every few hours when the app starts.">
<ToggleSwitch checked={autoCheck} onChange={setAutoCheck} />
</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}
>
{updateAvailable ? (
<Button
variant="default"
size="sm"
onPress={onInstall}
disabled={installing}
>
{installButtonLabel(installing, installProgress, apkAsset)}
</Button>
) : (
<Button
variant="outline"
size="sm"
onPress={() => void checkNow({ force: true })}
disabled={checking}
>
{checking ? 'Checking…' : 'Check now'}
</Button>
)}
</SettingItem>
{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>
<Text style={styles.notesBody} numberOfLines={20}>
{cachedLatest.body.trim()}
</Text>
</View>
) : null}
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
value: { ...typography.bodyMedium, color: colors.text },
notesBox: {
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
gap: spacing.sm,
},
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
progressBox: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: 6,
},
progressTrack: {
height: 6,
borderRadius: 3,
backgroundColor: colors.muted,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: colors.primary,
},
progressLabel: { ...typography.caption, color: colors.mutedForeground },
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { useVacationStore } from '../../stores/vacation-store';
function toJmapDate(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" — normalize to "YYYY-MM-DDTHH:MM:SS".
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" - normalize to "YYYY-MM-DDTHH:MM:SS".
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
const dateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(trimmed);
if (dateOnly) return `${trimmed}T00:00:00`;
+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;
}
@@ -14,7 +14,7 @@ function ev(partial: Partial<CalendarEvent>): CalendarEvent {
} as CalendarEvent;
}
describe('expandRecurringEvents — daily', () => {
describe('expandRecurringEvents - daily', () => {
it('expands a daily rule with count', () => {
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
const result = expandRecurringEvents(
@@ -52,9 +52,9 @@ describe('expandRecurringEvents — daily', () => {
});
});
describe('expandRecurringEvents — weekly', () => {
describe('expandRecurringEvents - weekly', () => {
it('expands a weekly rule on specific byDay values', () => {
// April 6, 2026 is a Monday — expand Mon/Wed for two weeks.
// April 6, 2026 is a Monday - expand Mon/Wed for two weeks.
const rule: RecurrenceRule = {
frequency: 'weekly',
byDay: [{ day: 'mo' }, { day: 'we' }],
@@ -74,7 +74,7 @@ describe('expandRecurringEvents — weekly', () => {
});
});
describe('expandRecurringEvents — monthly', () => {
describe('expandRecurringEvents - monthly', () => {
it('expands monthly on the 6th', () => {
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
const result = expandRecurringEvents(
@@ -108,7 +108,7 @@ describe('expandRecurringEvents — monthly', () => {
});
});
describe('expandRecurringEvents — overrides', () => {
describe('expandRecurringEvents - overrides', () => {
it('skips occurrences marked excluded', () => {
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
const event = ev({
@@ -146,7 +146,7 @@ describe('expandRecurringEvents — overrides', () => {
});
});
describe('expandRecurringEvents — non-recurring passthrough', () => {
describe('expandRecurringEvents - non-recurring passthrough', () => {
it('returns non-recurring events unchanged', () => {
const single = ev({ id: 'one', recurrenceRules: undefined });
const result = expandRecurringEvents(
@@ -159,7 +159,7 @@ describe('expandRecurringEvents — non-recurring passthrough', () => {
});
});
describe('expandRecurringEvents — range filtering', () => {
describe('expandRecurringEvents - range filtering', () => {
it('returns occurrences only within the requested window', () => {
const rule: RecurrenceRule = { frequency: 'daily', count: 30 };
const result = expandRecurringEvents(
+4 -4
View File
@@ -1,10 +1,10 @@
// Two avatar schemes, ported verbatim from the webmail:
//
// 1. Account avatar (account-utils.ts) — 12-color fixed palette, hashed from
// 1. Account avatar (account-utils.ts) - 12-color fixed palette, hashed from
// the account's email. Used for the sidebar account block and account
// switcher.
//
// 2. Email-row avatar (components/ui/avatar.tsx) — HSL hue from a hash of
// 2. Email-row avatar (components/ui/avatar.tsx) - HSL hue from a hash of
// name or email. Used for per-message avatars in the email list.
//
// Both variants also define `getInitials` with slightly different rules, so we
@@ -68,7 +68,7 @@ export function getEmailInitials(name: string, email?: string): string {
// logo from DuckDuckGo. The webmail goes through `/api/favicon?domain=...` as
// a caching proxy; native has no CORS constraint, so we hit DuckDuckGo direct.
// Multi-part TLDs (trimmed to the webmail client-side list — not the giant
// Multi-part TLDs (trimmed to the webmail client-side list - not the giant
// server PSL). Covers the common cases so subdomains resolve to the right
// registrable domain (e.g. newsletter.example.co.uk → example.co.uk).
const MULTI_PART_TLDS = new Set([
@@ -100,7 +100,7 @@ const MULTI_PART_TLDS = new Set([
'co.ke', 'or.ke', 'ac.ke', 'go.ke', 'ne.ke',
]);
// Personal mail providers — the favicon here is the mail provider's logo, not
// Personal mail providers - the favicon here is the mail provider's logo, not
// anything specific to the sender, so we skip it.
const PERSONAL_DOMAINS = new Set([
'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com',
+4 -4
View File
@@ -87,7 +87,7 @@ export function eventsOnDay(events: CalendarEvent[], day: Date): CalendarEvent[]
// O(days × events) with two parseISO calls per event per day. For ~200 events
// that's ~16k parseISO calls per month render, redone on every swipe.
// `buildEventDayIndex` parses each event's start/end once, then walks only the
// days that event actually touches — typically 1 day. Result is a map from
// days that event actually touches - typically 1 day. Result is a map from
// local-date key (yyyy-MM-dd) to the events on that day.
export type EventDayIndex = Map<string, CalendarEvent[]>;
@@ -104,7 +104,7 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
const start = getEventStartDate(event);
const end = getEventDisplayEndDate(event);
let day = startOfDay(start);
// Cap at 366 days: matches the original semantics (end > dayStart) — we
// Cap at 366 days: matches the original semantics (end > dayStart) - we
// include day while day < end. Safety bound guards against malformed
// multi-year events that would otherwise blow up the loop.
let safety = 0;
@@ -193,7 +193,7 @@ export function getTimedEventBoundsForDay(
// ─── Timed events that fill the day ──────────────────────
// Events shown in the timed grid that effectively span 00:00–24:00 on the
// given day get promoted to the all-day strip in the week/day views (matches
// webmail behavior — keeps the timed grid usable when an event covers the day).
// webmail behavior - keeps the timed grid usable when an event covers the day).
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
@@ -315,7 +315,7 @@ export function buildTimedFullDayWeekSegments(
// begins after every prior event has ended. Within a cluster every event gets
// the same `totalColumns`; across clusters, events get to use the full width
// they actually need. The previous global packing forced an event with no
// neighbors at 17:00 to share columns with a 9am cluster — visible squish.
// neighbors at 17:00 to share columns with a 9am cluster - visible squish.
export interface TimedEventLayout {
event: CalendarEvent;
column: number;
+41
View File
@@ -0,0 +1,41 @@
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
}
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
const r = await fetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text();
}
function safeFilename(subject: string | undefined): string {
const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email';
return `${base}.eml`;
}
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() },
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
});
}
+4 -4
View File
@@ -6,7 +6,7 @@
// so inline <script>, inline event handlers, and external resources are
// blocked by the engine itself. This is the primary boundary.
// 2. `onShouldStartLoadWithRequest` refuses any navigation except the
// initial about:blank doc — links are opened in the OS browser.
// initial about:blank doc - links are opened in the OS browser.
// 3. This module pre-strips known-dangerous tags and on* attributes so the
// rendered DOM is also clean. Regex stripping is imperfect; it is here
// as belt-and-suspenders, not the primary defense.
@@ -82,7 +82,7 @@ export function plainTextToSafeHtml(text: string): string {
);
}
// Base styles for HTML emails — mirrors the webmail iframe body 1:1.
// Base styles for HTML emails - mirrors the webmail iframe body 1:1.
// Emails are authored for light mode; we render them true-to-life and apply a
// filter inversion trick for dark mode (unless the email has native dark
// support via @media (prefers-color-scheme: dark)).
@@ -171,7 +171,7 @@ export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): str
// Strict CSP. Inline <script> and handlers from the email are stripped by
// `stripDangerousTags`, so script-src only needs to allow our own injected
// measurement bridge — 'unsafe-inline' is the minimum that lets RN WebView's
// measurement bridge - 'unsafe-inline' is the minimum that lets RN WebView's
// injected script run on Android (where page CSP can apply to evaluateJs).
const imgSrc = blockRemoteImages ? "img-src data: cid:" : "img-src data: cid: https: http:";
const csp = [
@@ -286,7 +286,7 @@ function blockRemoteImageSrcs(html: string): string {
// Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts).
// Returns false when the HTML is an auto-generated minimal wrapper around plain
// text (no <br>, no links, no rich tags) — the caller should fall back to the
// text (no <br>, no links, no rich tags) - the caller should fall back to the
// textBody in that case, since the server-side HTML often collapses newlines.
const MEANINGFUL_HTML_RE =
/<(?:table|img|style|b|strong|i|em|u|font|h[1-6]|ul|ol|blockquote|br)\b|<a\b[^>]*\bhref=|<(?:div|span|p)\b[^>]*\bstyle=/i;
+78
View File
@@ -0,0 +1,78 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as FileSystemLegacy from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import type { ReleaseAsset } from '../api/updates';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
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();
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,
type: 'application/vnd.android.package-archive',
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' },
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ interface BulwarkFcmNative {
}
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
// short-lived headless JS runtime — keep it fast, catch all errors, and always
// short-lived headless JS runtime - keep it fast, catch all errors, and always
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
try {
+5 -5
View File
@@ -18,11 +18,11 @@ const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Hosted relay so users don't need to run their own Firebase project. The
// relay only ever sees FCM tokens + JMAP state-id hashes — no mail content.
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
// Power users can override this from the settings screen.
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
// Types the mobile app wants StateChange pings for. Submission is excluded —
// Types the mobile app wants StateChange pings for. Submission is excluded -
// outgoing mail state changes don't belong in a user-visible push.
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
@@ -37,7 +37,7 @@ function getNative(): BulwarkFcmNative | null {
}
export interface PushSetupParams {
// Optional — falls back to the hosted relay if omitted.
// Optional - falls back to the hosted relay if omitted.
relayBaseUrl?: string;
accountLabel?: string;
}
@@ -213,7 +213,7 @@ export async function setupPushNotifications(
/**
* Tear down push on logout / disable. Removes the server-side JMAP
* subscription, tells the relay to drop the mapping, and clears the local
* FCM token. Never throws — callers treat teardown as best effort.
* FCM token. Never throws - callers treat teardown as best effort.
*/
export async function teardownPushNotifications(): Promise<void> {
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
@@ -266,7 +266,7 @@ export interface NotificationTapPayload {
subject?: string;
}
// Returns — and clears — any pending "notification tap" that launched the app
// Returns - and clears - any pending "notification tap" that launched the app
// before JS was ready to handle it. Subsequent taps while running are delivered
// via addNotificationTapListener.
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
+17
View File
@@ -0,0 +1,17 @@
export function parseVersion(v: string): [number, number, number] {
const cleaned = v.replace(/^v/, '').split(/[-+]/)[0];
const parts = cleaned.split('.').map((n) => parseInt(n, 10));
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
}
export function compareVersions(a: string, b: string): number {
const [a1, a2, a3] = parseVersion(a);
const [b1, b2, b3] = parseVersion(b);
if (a1 !== b1) return a1 - b1;
if (a2 !== b2) return a2 - b2;
return a3 - b3;
}
export function isNewer(latest: string, current: string): boolean {
return compareVersions(latest, current) > 0;
}
+1
View File
@@ -3,6 +3,7 @@ import type { EmailAddress } from '../api/types';
export type RootStackParamList = {
MainTabs: undefined;
EmailThread: { emailId: string; threadId: string; subject?: string };
EmailSource: { emailId: string; blobId: string; subject?: string };
Compose:
| {
mode?: 'reply' | 'replyAll' | 'forward';
+499 -118
View File
@@ -1,165 +1,546 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react-native';
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isToday, addMonths, subMonths } from 'date-fns';
import { colors } from '../theme/colors';
import {
View,
Text,
StyleSheet,
Pressable,
ActivityIndicator,
ScrollView,
RefreshControl,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
ChevronLeft,
ChevronRight,
Plus,
CalendarDays,
LayoutGrid,
List as ListIcon,
Menu,
} from 'lucide-react-native';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
isToday,
addMonths,
subMonths,
addWeeks,
subWeeks,
} from 'date-fns';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { Button } from '../components';
import { useCalendarStore } from '../stores/calendar-store';
import { MonthView } from '../components/calendar/MonthView';
import { WeekView } from '../components/calendar/WeekView';
import { AgendaView } from '../components/calendar/AgendaView';
import { EventCard } from '../components/calendar/EventCard';
import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
import { EventModal } from '../components/calendar/EventModal';
import {
RecurrenceScopeDialog,
type RecurrenceEditScope,
} from '../components/calendar/RecurrenceScopeDialog';
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
type EventDayIndex,
} from '../lib/calendar-utils';
import type { Calendar, CalendarEvent } from '../api/types';
const MOCK_EVENTS = [
{ id: '1', title: 'Team Standup', time: '09:00', color: '#3b82f6' },
{ id: '2', title: 'Design Review', time: '11:00', color: '#8b5cf6' },
{ id: '3', title: 'Lunch with Sarah', time: '12:30', color: '#22c55e' },
{ id: '4', title: 'Sprint Planning', time: '14:00', color: '#f59e0b' },
];
type ViewMode = 'month' | 'week' | 'agenda';
type PendingAction =
| { kind: 'edit'; event: CalendarEvent }
| { kind: 'delete'; event: CalendarEvent }
| null;
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const AGENDA_DAYS = 30;
const RANGE_BUFFER_DAYS = 14;
function MonthGrid({ currentDate }: { currentDate: Date }) {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calStart = startOfWeek(monthStart);
const calEnd = endOfWeek(monthEnd);
const days: Date[] = [];
let day = calStart;
while (day <= calEnd) {
days.push(day);
day = addDays(day, 1);
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
if (viewMode === 'month') {
const start = startOfWeek(startOfMonth(currentDate));
const end = endOfWeek(endOfMonth(currentDate));
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
return {
after: addDays(currentDate, -1),
before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS),
};
}
return (
<View>
<View style={styles.weekdayRow}>
{WEEKDAYS.map((wd) => (
<Text key={wd} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
<View style={styles.daysGrid}>
{days.map((d, i) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
return (
<View key={i} style={styles.dayCell}>
<View style={[styles.dayNumber, today && styles.todayCircle]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
]}>
{format(d, 'd')}
</Text>
</View>
</View>
);
})}
</View>
</View>
);
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
if (start.getMonth() === end.getMonth()) {
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
}
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
}
return format(currentDate, 'MMMM yyyy');
}
export default function CalendarScreen() {
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
const [modalVisible, setModalVisible] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
const [sidebarVisible, setSidebarVisible] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const hydrate = useCalendarStore((s) => s.hydrate);
const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
const ensureRange = useCalendarStore((s) => s.ensureRange);
const refresh = useCalendarStore((s) => s.refresh);
const createEvent = useCalendarStore((s) => s.createEvent);
const updateEvent = useCalendarStore((s) => s.updateEvent);
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const allCalendars = useCalendarStore((s) => s.calendars);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
const error = useCalendarStore((s) => s.error);
const allEvents = useCalendarStore((s) => s.events);
const calendars = React.useMemo(
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
[allCalendars, hiddenCalendarIds],
);
const events = React.useMemo(() => {
if (hiddenCalendarIds.length === 0) return allEvents;
const hidden = new Set(hiddenCalendarIds);
return allEvents.filter((e) => {
const ids = Object.keys(e.calendarIds || {});
if (ids.length === 0) return true;
return ids.some((id) => !hidden.has(id));
});
}, [allEvents, hiddenCalendarIds]);
// Pre-index events by day once. Child views do O(1) map lookups per cell
// instead of re-filtering the full event list with parseISO per day.
const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]);
React.useEffect(() => {
void hydrate();
void fetchCalendarsAction();
}, [hydrate, fetchCalendarsAction]);
React.useEffect(() => {
const { after, before } = rangeForView(viewMode, currentDate);
void ensureRange(after.toISOString(), before.toISOString());
}, [viewMode, currentDate, ensureRange]);
const goPrev = React.useCallback(() => {
setCurrentDate((d) => {
if (viewMode === 'month') return subMonths(d, 1);
if (viewMode === 'week') return subWeeks(d, 1);
return addDays(d, -AGENDA_DAYS);
});
}, [viewMode]);
const goNext = React.useCallback(() => {
setCurrentDate((d) => {
if (viewMode === 'month') return addMonths(d, 1);
if (viewMode === 'week') return addWeeks(d, 1);
return addDays(d, AGENDA_DAYS);
});
}, [viewMode]);
const goToday = React.useCallback(() => {
const today = new Date();
setCurrentDate(today);
setSelectedDate(today);
}, []);
const handleSelectDate = React.useCallback(
(date: Date) => {
setSelectedDate(date);
if (viewMode === 'week') setCurrentDate(date);
},
[viewMode],
);
const openCreate = React.useCallback((date?: Date) => {
setModalEvent(null);
setModalDate(date);
setModalVisible(true);
}, []);
const openEditDirect = React.useCallback((event: CalendarEvent) => {
setModalEvent(event);
setModalDate(undefined);
setModalVisible(true);
}, []);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
const action = pendingAction;
setPendingAction(null);
if (!action) return;
// For the first pass, treat all scopes as "all" - proper override
// handling lives in the store's update/delete path against originalId.
// TODO: implement "this only" via recurrenceOverrides write, and
// "this and following" via excludedRecurrenceRules + new master.
if (action.kind === 'edit') {
openEditDirect(action.event);
} else {
await deleteEvent(action.event.id);
}
},
[pendingAction, openEditDirect, deleteEvent],
);
const handleSave = React.useCallback(
async (data: Partial<CalendarEvent>, calendarId: string) => {
if (modalEvent) {
await updateEvent(modalEvent.id, data);
} else {
await createEvent(data, calendarId);
}
},
[modalEvent, createEvent, updateEvent],
);
const handleDeleteFromModal = React.useCallback(
async (event: CalendarEvent) => {
setModalVisible(false);
await deleteEvent(event.id);
},
[deleteEvent],
);
const onRefresh = React.useCallback(async () => {
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
}, [refresh]);
const isSelectedToday = isToday(selectedDate);
return (
<ScrollView style={styles.container}>
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<CalendarDays size={24} color={colors.primary} />
<Text style={styles.headerTitle}>Calendar</Text>
<Pressable
onPress={() => setSidebarVisible(true)}
hitSlop={8}
style={styles.headerBtn}
>
<Menu size={20} color={colors.text} />
</Pressable>
<View style={styles.headerLeft}>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
<Text style={styles.headerSubtitle}>
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
</Text>
</View>
<View style={styles.headerActions}>
<View style={styles.viewToggle}>
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
const Icon =
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
const active = viewMode === mode;
return (
<Pressable
key={mode}
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
onPress={() => setViewMode(mode)}
>
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
</Pressable>
);
})}
</View>
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
<Plus size={18} color={colors.primaryForeground} />
</Pressable>
</View>
</View>
<View style={styles.monthNav}>
<Pressable onPress={() => setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
<View style={styles.nav}>
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
<ChevronLeft size={20} color={colors.text} />
</Pressable>
<Text style={styles.monthLabel}>{format(currentDate, 'MMMM yyyy')}</Text>
<Pressable onPress={() => setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
<Button variant="outline" size="sm" onPress={goToday}>
Today
</Button>
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
<ChevronRight size={20} color={colors.text} />
</Pressable>
</View>
<MonthGrid currentDate={currentDate} />
{error && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={styles.todaySection}>
<Text style={styles.todaySectionTitle}>Today's Events</Text>
{MOCK_EVENTS.map((event) => (
<Pressable key={event.id} style={styles.eventCard}>
<View style={[styles.eventDot, { backgroundColor: event.color }]} />
<View style={styles.eventContent}>
<Text style={styles.eventTitle}>{event.title}</Text>
<Text style={styles.eventTime}>{event.time}</Text>
<View style={styles.content}>
{viewMode === 'month' && (
<MonthView
currentDate={currentDate}
selectedDate={selectedDate}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectDate={handleSelectDate}
onLongPressDate={openCreate}
/>
)}
{viewMode === 'week' && (
<WeekView
selectedDate={selectedDate}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={setDetailEvent}
onCreateAtTime={openCreate}
/>
)}
{viewMode === 'agenda' && (
<AgendaView
fromDate={currentDate}
daysAhead={AGENDA_DAYS}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectEvent={setDetailEvent}
/>
)}
{viewMode === 'month' && (
<View style={styles.dayDetail}>
<View style={styles.dayDetailHeader}>
<Text style={styles.dayDetailTitle}>
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
</Text>
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
</View>
</Pressable>
))}
<DayEventList
date={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectEvent={setDetailEvent}
refreshing={refreshing}
onRefresh={onRefresh}
/>
</View>
)}
</View>
<EventDetailSheet
event={detailEvent}
calendars={calendars}
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
/>
<EventModal
visible={modalVisible}
event={modalEvent}
calendars={allCalendars}
defaultDate={modalDate}
onSave={handleSave}
onDelete={handleDeleteFromModal}
onClose={() => setModalVisible(false)}
/>
<RecurrenceScopeDialog
visible={!!pendingAction}
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
onSelect={handleScopeSelect}
onClose={() => setPendingAction(null)}
/>
<CalendarSidebarDrawer
visible={sidebarVisible}
calendars={allCalendars}
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
onClose={() => setSidebarVisible(false)}
/>
</SafeAreaView>
);
}
function DayEventList({
date,
eventsByDay,
calendars,
onSelectEvent,
refreshing,
onRefresh,
}: {
date: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
onSelectEvent?: (event: CalendarEvent) => void;
refreshing: boolean;
onRefresh: () => void;
}) {
const dayEvents = React.useMemo(
() => eventsOnDayFromIndex(eventsByDay, date),
[eventsByDay, date],
);
if (dayEvents.length === 0) {
return (
<ScrollView
contentContainerStyle={styles.emptyState}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
}
>
<CalendarDays size={32} color={colors.surfaceActive} />
<Text style={styles.emptyTitle}>No events</Text>
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
</ScrollView>
);
}
return (
<ScrollView
contentContainerStyle={styles.dayList}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
}
>
{dayEvents.map((event) => (
<EventCard
key={event.id}
event={event}
calendars={calendars}
onPress={onSelectEvent}
/>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: 8,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
monthNav: {
flexDirection: 'row',
headerBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
justifyContent: 'center',
borderRadius: radius.full,
},
navBtn: { padding: 8 },
monthLabel: { fontSize: 17, fontWeight: '600', color: colors.text },
weekdayRow: {
headerLeft: { flex: 1 },
headerTitle: { ...typography.h3, color: colors.text },
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
viewToggle: {
flexDirection: 'row',
paddingHorizontal: 8,
marginBottom: 4,
backgroundColor: colors.surface,
borderRadius: radius.md,
padding: 2,
},
weekdayLabel: {
flex: 1,
textAlign: 'center',
fontSize: 12,
fontWeight: '600',
color: colors.textMuted,
},
daysGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 8,
},
dayCell: {
width: '14.28%',
alignItems: 'center',
paddingVertical: 6,
},
dayNumber: {
viewToggleBtn: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
viewToggleBtnActive: { backgroundColor: colors.background },
fab: {
width: 36,
height: 36,
borderRadius: radius.full,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
dayText: { fontSize: 14, color: colors.text },
dayTextMuted: { color: colors.textMuted },
todayCircle: { backgroundColor: colors.primary },
todayText: { color: colors.textInverse, fontWeight: '700' },
todaySection: { paddingHorizontal: 16, paddingTop: 20 },
todaySectionTitle: { fontSize: 16, fontWeight: '600', color: colors.text, marginBottom: 12 },
eventCard: {
nav: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderRadius: 10,
padding: 12,
marginBottom: 8,
justifyContent: 'center',
paddingVertical: spacing.xs,
gap: spacing.lg,
},
eventDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
eventContent: { flex: 1 },
eventTitle: { fontSize: 15, fontWeight: '500', color: colors.text },
eventTime: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
navBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
},
errorBanner: {
backgroundColor: colors.errorBg,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
errorText: { ...typography.caption, color: colors.errorForeground },
content: { flex: 1 },
dayDetail: {
flex: 1,
borderTopWidth: 1,
borderTopColor: colors.border,
},
dayDetailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
emptyState: {
alignItems: 'center',
paddingVertical: spacing.xl,
gap: spacing.xs,
flexGrow: 1,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
-546
View File
@@ -1,546 +0,0 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
Pressable,
ActivityIndicator,
ScrollView,
RefreshControl,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
ChevronLeft,
ChevronRight,
Plus,
CalendarDays,
LayoutGrid,
List as ListIcon,
Menu,
} from 'lucide-react-native';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
isToday,
addMonths,
subMonths,
addWeeks,
subWeeks,
} from 'date-fns';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { Button } from '../components';
import { useCalendarStore } from '../stores/calendar-store';
import { MonthView } from '../components/calendar/MonthView';
import { WeekView } from '../components/calendar/WeekView';
import { AgendaView } from '../components/calendar/AgendaView';
import { EventCard } from '../components/calendar/EventCard';
import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
import { EventModal } from '../components/calendar/EventModal';
import {
RecurrenceScopeDialog,
type RecurrenceEditScope,
} from '../components/calendar/RecurrenceScopeDialog';
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
type EventDayIndex,
} from '../lib/calendar-utils';
import type { Calendar, CalendarEvent } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
type PendingAction =
| { kind: 'edit'; event: CalendarEvent }
| { kind: 'delete'; event: CalendarEvent }
| null;
const AGENDA_DAYS = 30;
const RANGE_BUFFER_DAYS = 14;
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
if (viewMode === 'month') {
const start = startOfWeek(startOfMonth(currentDate));
const end = endOfWeek(endOfMonth(currentDate));
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
return {
after: addDays(currentDate, -1),
before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS),
};
}
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
if (start.getMonth() === end.getMonth()) {
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
}
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
}
return format(currentDate, 'MMMM yyyy');
}
export default function CalendarScreen() {
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
const [modalVisible, setModalVisible] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
const [sidebarVisible, setSidebarVisible] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const hydrate = useCalendarStore((s) => s.hydrate);
const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
const ensureRange = useCalendarStore((s) => s.ensureRange);
const refresh = useCalendarStore((s) => s.refresh);
const createEvent = useCalendarStore((s) => s.createEvent);
const updateEvent = useCalendarStore((s) => s.updateEvent);
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const allCalendars = useCalendarStore((s) => s.calendars);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
const error = useCalendarStore((s) => s.error);
const allEvents = useCalendarStore((s) => s.events);
const calendars = React.useMemo(
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
[allCalendars, hiddenCalendarIds],
);
const events = React.useMemo(() => {
if (hiddenCalendarIds.length === 0) return allEvents;
const hidden = new Set(hiddenCalendarIds);
return allEvents.filter((e) => {
const ids = Object.keys(e.calendarIds || {});
if (ids.length === 0) return true;
return ids.some((id) => !hidden.has(id));
});
}, [allEvents, hiddenCalendarIds]);
// Pre-index events by day once. Child views do O(1) map lookups per cell
// instead of re-filtering the full event list with parseISO per day.
const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]);
React.useEffect(() => {
void hydrate();
void fetchCalendarsAction();
}, [hydrate, fetchCalendarsAction]);
React.useEffect(() => {
const { after, before } = rangeForView(viewMode, currentDate);
void ensureRange(after.toISOString(), before.toISOString());
}, [viewMode, currentDate, ensureRange]);
const goPrev = React.useCallback(() => {
setCurrentDate((d) => {
if (viewMode === 'month') return subMonths(d, 1);
if (viewMode === 'week') return subWeeks(d, 1);
return addDays(d, -AGENDA_DAYS);
});
}, [viewMode]);
const goNext = React.useCallback(() => {
setCurrentDate((d) => {
if (viewMode === 'month') return addMonths(d, 1);
if (viewMode === 'week') return addWeeks(d, 1);
return addDays(d, AGENDA_DAYS);
});
}, [viewMode]);
const goToday = React.useCallback(() => {
const today = new Date();
setCurrentDate(today);
setSelectedDate(today);
}, []);
const handleSelectDate = React.useCallback(
(date: Date) => {
setSelectedDate(date);
if (viewMode === 'week') setCurrentDate(date);
},
[viewMode],
);
const openCreate = React.useCallback((date?: Date) => {
setModalEvent(null);
setModalDate(date);
setModalVisible(true);
}, []);
const openEditDirect = React.useCallback((event: CalendarEvent) => {
setModalEvent(event);
setModalDate(undefined);
setModalVisible(true);
}, []);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
const action = pendingAction;
setPendingAction(null);
if (!action) return;
// For the first pass, treat all scopes as "all" — proper override
// handling lives in the store's update/delete path against originalId.
// TODO: implement "this only" via recurrenceOverrides write, and
// "this and following" via excludedRecurrenceRules + new master.
if (action.kind === 'edit') {
openEditDirect(action.event);
} else {
await deleteEvent(action.event.id);
}
},
[pendingAction, openEditDirect, deleteEvent],
);
const handleSave = React.useCallback(
async (data: Partial<CalendarEvent>, calendarId: string) => {
if (modalEvent) {
await updateEvent(modalEvent.id, data);
} else {
await createEvent(data, calendarId);
}
},
[modalEvent, createEvent, updateEvent],
);
const handleDeleteFromModal = React.useCallback(
async (event: CalendarEvent) => {
setModalVisible(false);
await deleteEvent(event.id);
},
[deleteEvent],
);
const onRefresh = React.useCallback(async () => {
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
}, [refresh]);
const isSelectedToday = isToday(selectedDate);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable
onPress={() => setSidebarVisible(true)}
hitSlop={8}
style={styles.headerBtn}
>
<Menu size={20} color={colors.text} />
</Pressable>
<View style={styles.headerLeft}>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
<Text style={styles.headerSubtitle}>
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
</Text>
</View>
<View style={styles.headerActions}>
<View style={styles.viewToggle}>
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
const Icon =
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
const active = viewMode === mode;
return (
<Pressable
key={mode}
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
onPress={() => setViewMode(mode)}
>
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
</Pressable>
);
})}
</View>
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
<Plus size={18} color={colors.primaryForeground} />
</Pressable>
</View>
</View>
<View style={styles.nav}>
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
<ChevronLeft size={20} color={colors.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} />
</Pressable>
</View>
{error && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={styles.content}>
{viewMode === 'month' && (
<MonthView
currentDate={currentDate}
selectedDate={selectedDate}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectDate={handleSelectDate}
onLongPressDate={(date) => openCreate(date)}
/>
)}
{viewMode === 'week' && (
<WeekView
selectedDate={selectedDate}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={setDetailEvent}
onCreateAtTime={(date) => openCreate(date)}
/>
)}
{viewMode === 'agenda' && (
<AgendaView
fromDate={currentDate}
daysAhead={AGENDA_DAYS}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectEvent={setDetailEvent}
/>
)}
{viewMode === 'month' && (
<View style={styles.dayDetail}>
<View style={styles.dayDetailHeader}>
<Text style={styles.dayDetailTitle}>
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
</Text>
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
</View>
<DayEventList
date={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectEvent={setDetailEvent}
refreshing={refreshing}
onRefresh={onRefresh}
/>
</View>
)}
</View>
<EventDetailSheet
event={detailEvent}
calendars={calendars}
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
/>
<EventModal
visible={modalVisible}
event={modalEvent}
calendars={allCalendars}
defaultDate={modalDate}
onSave={handleSave}
onDelete={handleDeleteFromModal}
onClose={() => setModalVisible(false)}
/>
<RecurrenceScopeDialog
visible={!!pendingAction}
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
onSelect={handleScopeSelect}
onClose={() => setPendingAction(null)}
/>
<CalendarSidebarDrawer
visible={sidebarVisible}
calendars={allCalendars}
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
onClose={() => setSidebarVisible(false)}
/>
</SafeAreaView>
);
}
function DayEventList({
date,
eventsByDay,
calendars,
onSelectEvent,
refreshing,
onRefresh,
}: {
date: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
onSelectEvent?: (event: CalendarEvent) => void;
refreshing: boolean;
onRefresh: () => void;
}) {
const dayEvents = React.useMemo(
() => eventsOnDayFromIndex(eventsByDay, date),
[eventsByDay, date],
);
if (dayEvents.length === 0) {
return (
<ScrollView
contentContainerStyle={styles.emptyState}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
}
>
<CalendarDays size={32} color={colors.surfaceActive} />
<Text style={styles.emptyTitle}>No events</Text>
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
</ScrollView>
);
}
return (
<ScrollView
contentContainerStyle={styles.dayList}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
}
>
{dayEvents.map((event) => (
<EventCard
key={event.id}
event={event}
calendars={calendars}
onPress={onSelectEvent}
/>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
headerBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
},
headerLeft: { flex: 1 },
headerTitle: { ...typography.h3, color: colors.text },
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
viewToggle: {
flexDirection: 'row',
backgroundColor: colors.surface,
borderRadius: radius.md,
padding: 2,
},
viewToggleBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
viewToggleBtnActive: { backgroundColor: colors.background },
fab: {
width: 36,
height: 36,
borderRadius: radius.full,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
nav: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xs,
gap: spacing.lg,
},
navBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
},
errorBanner: {
backgroundColor: colors.errorBg,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
errorText: { ...typography.caption, color: colors.errorForeground },
content: { flex: 1 },
dayDetail: {
flex: 1,
borderTopWidth: 1,
borderTopColor: colors.border,
},
dayDetailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
emptyState: {
alignItems: 'center',
paddingVertical: spacing.xl,
gap: spacing.xs,
flexGrow: 1,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
+460 -70
View File
@@ -1,110 +1,500 @@
import React from 'react';
import { View, Text, StyleSheet, SectionList, Pressable } from 'react-native';
import { Users } from 'lucide-react-native';
import { colors } from '../theme/colors';
import {
View,
Text,
StyleSheet,
SectionList,
Pressable,
TextInput,
ActivityIndicator,
RefreshControl,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
import {
useContactsStore,
sortContactsByDisplayName,
selectGroupMembers,
type ContactCategory,
} from '../stores/contacts-store';
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
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';
const MOCK_CONTACTS = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', phone: '+1 555-0101' },
{ id: '2', name: 'Bob Smith', email: 'bob@example.com', phone: '+1 555-0102' },
{ id: '3', name: 'Carol Davis', email: 'carol@example.com', phone: '+1 555-0103' },
{ id: '4', name: 'David Wilson', email: 'david@example.com', phone: '+1 555-0104' },
{ id: '5', name: 'Emma Brown', email: 'emma@example.com', phone: '+1 555-0105' },
{ id: '6', name: 'Frank Miller', email: 'frank@example.com', phone: '+1 555-0106' },
{ id: '7', name: 'Grace Lee', email: 'grace@example.com', phone: '+1 555-0107' },
{ id: '8', name: 'Henry Taylor', email: 'henry@example.com', phone: '+1 555-0108' },
];
type Nav = NativeStackNavigationProp<RootStackParamList>;
function groupByLetter(contacts: typeof MOCK_CONTACTS) {
const groups: Record<string, typeof MOCK_CONTACTS> = {};
contacts.forEach((c) => {
const letter = c.name.charAt(0).toUpperCase();
if (!groups[letter]) groups[letter] = [];
groups[letter].push(c);
});
return Object.keys(groups)
.sort()
.map((letter) => ({ title: letter, data: groups[letter] }));
interface Section {
title: string;
data: ContactCard[];
}
function ContactRow({ item }: { item: typeof MOCK_CONTACTS[0] }) {
return (
<Pressable style={({ pressed }) => [styles.contactRow, pressed && styles.contactRowPressed]}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{item.name.charAt(0)}</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactName}>{item.name}</Text>
<Text style={styles.contactEmail}>{item.email}</Text>
</View>
</Pressable>
);
function groupContacts(contacts: ContactCard[]): Section[] {
const sorted = sortContactsByDisplayName(contacts);
const groups: Record<string, ContactCard[]> = {};
for (const c of sorted) {
const name = getContactDisplayName(c).trim();
const letter = (name[0] || '#').toUpperCase();
const key = /[A-Z]/.test(letter) ? letter : '#';
if (!groups[key]) groups[key] = [];
groups[key].push(c);
}
return Object.keys(groups)
.sort((a, b) => {
if (a === '#') return 1;
if (b === '#') return -1;
return a.localeCompare(b);
})
.map((title) => ({ title, data: groups[title] }));
}
function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
switch (category.type) {
case 'all':
return 'All Contacts';
case 'addressBook':
return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
case 'group': {
const g = groups.find((c) => c.id === category.groupId);
return g ? getContactDisplayName(g) || 'Group' : 'Group';
}
case 'keyword':
return `#${category.keyword}`;
case 'uncategorized':
return 'Uncategorized';
default:
return 'Contacts';
}
}
export default function ContactsScreen() {
const sections = React.useMemo(() => groupByLetter(MOCK_CONTACTS), []);
const navigation = useNavigation<Nav>();
const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks);
const loading = useContactsStore((s) => s.loading);
const error = useContactsStore((s) => s.error);
const selectedCategory = useContactsStore((s) => s.selectedCategory);
const hydrated = useContactsStore((s) => s.hydrated);
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const [searchQuery, setSearchQuery] = React.useState('');
const [searchActive, setSearchActive] = React.useState(false);
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [selection, setSelection] = React.useState<Set<string>>(new Set());
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
React.useEffect(() => {
void hydrate();
}, [hydrate]);
React.useEffect(() => {
void fetchAddressBooks();
void fetchContacts();
}, [fetchAddressBooks, fetchContacts]);
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const visible = React.useMemo(() => {
let filtered: ContactCard[];
switch (selectedCategory.type) {
case 'all':
filtered = individuals;
break;
case 'addressBook':
filtered = individuals.filter((c) => c.addressBookIds?.[selectedCategory.addressBookId]);
break;
case 'group':
filtered = selectGroupMembers(
{ contacts } as Parameters<typeof selectGroupMembers>[0],
selectedCategory.groupId,
).filter((c) => !isGroup(c));
break;
case 'keyword':
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
break;
case 'uncategorized':
filtered = individuals.filter(
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
);
break;
default:
filtered = individuals;
}
if (!searchQuery) return filtered;
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
}, [contacts, individuals, selectedCategory, searchQuery]);
const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible);
// Flat list - single unnamed section keeps SectionList rendering simple.
return [{ title: '', data: sortContactsByDisplayName(visible) }];
}, [visible, groupByLetter]);
const selectionMode = selection.size > 0;
const toggleSelect = (id: string) => {
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleRowPress = (c: ContactCard) => {
if (selectionMode) {
toggleSelect(c.id);
return;
}
if (isGroup(c)) {
navigation.navigate('GroupDetail', { groupId: c.id });
} else {
navigation.navigate('ContactDetail', { contactId: c.id });
}
};
const handleLongPress = (c: ContactCard) => {
toggleSelect(c.id);
};
const clearSelection = () => setSelection(new Set());
const doBulkDelete = async () => {
const ids = Array.from(selection);
setConfirmBulkDelete(false);
clearSelection();
await bulkDelete(ids);
};
const title = categoryTitle(selectedCategory, addressBooks, groups);
// Top chip row: All / per-address-book / Groups
const chips = React.useMemo(() => {
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
{
key: 'all',
label: 'All',
category: { type: 'all' },
active: selectedCategory.type === 'all',
},
];
for (const book of addressBooks) {
items.push({
key: `book:${book.id}`,
label: book.name,
category: { type: 'addressBook', addressBookId: book.id },
active: selectedCategory.type === 'addressBook' && selectedCategory.addressBookId === book.id,
});
}
if (groups.length > 0) {
items.push({
key: 'groups-divider',
label: 'Groups',
category: { type: 'all' },
active: false,
});
for (const g of groups) {
items.push({
key: `group:${g.id}`,
label: getContactDisplayName(g) || 'Group',
category: { type: 'group', groupId: g.id },
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
});
}
}
return items;
}, [addressBooks, groups, selectedCategory]);
return (
<View style={styles.container}>
<SafeAreaView style={styles.container} edges={['top']}>
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
<View style={styles.header}>
<Users size={24} color={colors.primary} />
<Text style={styles.headerTitle}>Contacts</Text>
<Text style={styles.headerCount}>{MOCK_CONTACTS.length}</Text>
{selectionMode ? (
<>
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
<X size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => setConfirmBulkDelete(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<Trash2 size={20} color={colors.error} />
</Pressable>
</View>
</>
) : (
<>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
<Menu size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
<Text style={styles.headerCount}>{visible.length}</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => setSearchActive((v) => !v)}
style={styles.headerIconBtn}
hitSlop={8}
>
<Search size={20} color={colors.text} />
</Pressable>
<Pressable
onPress={() => navigation.navigate('ContactForm', {})}
style={styles.addBtn}
hitSlop={8}
>
<Plus size={18} color={colors.primaryForeground} />
</Pressable>
</View>
</>
)}
</View>
{searchActive && (
<View style={styles.searchBar}>
<Search size={16} color={colors.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
<X size={16} color={colors.textMuted} />
</Pressable>
)}
</View>
)}
{!selectionMode && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.chipBar}
contentContainerStyle={styles.chipBarContent}
>
{chips.map((chip) =>
chip.key === 'groups-divider' ? (
<View key={chip.key} style={styles.chipDivider} />
) : (
<Pressable
key={chip.key}
onPress={() => setSelectedCategory(chip.category)}
style={[styles.chip, chip.active && styles.chipActive]}
>
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
{chip.label}
</Text>
</Pressable>
),
)}
</ScrollView>
)}
{error && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ContactRow item={item} />}
renderSectionHeader={({ section: { title } }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{title}</Text>
</View>
renderItem={({ item }) => (
<ContactListRow
contact={item}
onPress={() => handleRowPress(item)}
onLongPress={() => handleLongPress(item)}
selected={selection.has(item.id)}
selectionMode={selectionMode}
/>
)}
renderSectionHeader={({ section }) =>
section.title ? (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{section.title}</Text>
</View>
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
stickySectionHeadersEnabled
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={loading && hydrated}
onRefresh={() => {
void fetchContacts();
void fetchAddressBooks();
}}
tintColor={colors.primary}
/>
}
ListEmptyComponent={
loading ? (
<View style={styles.emptyState}>
<ActivityIndicator color={colors.primary} />
</View>
) : (
<View style={styles.emptyState}>
<UserCircle size={48} color={colors.surfaceActive} />
<Text style={styles.emptyTitle}>
{searchQuery ? 'No contacts found' : 'No contacts yet'}
</Text>
<Text style={styles.emptySubtitle}>
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
</Text>
</View>
)
}
/>
</View>
<Dialog
visible={confirmBulkDelete}
title="Delete contacts"
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
variant="destructive"
confirmText="Delete"
onConfirm={doBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
listContent: { paddingBottom: 80 },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: 8,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
gap: spacing.sm,
},
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 },
headerCount: { fontSize: 13, color: colors.textSecondary },
listContent: { paddingBottom: 20 },
sectionHeader: {
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
headerCount: {
...typography.small,
color: colors.textMuted,
backgroundColor: colors.surface,
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
overflow: 'hidden',
},
sectionTitle: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
contactRow: {
headerActions: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
gap: spacing.sm,
},
contactRowPressed: { backgroundColor: colors.surfaceHover },
avatar: {
headerIconBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
borderRadius: radius.full,
},
avatarText: { fontSize: 16, fontWeight: '600', color: colors.textInverse },
contactInfo: { flex: 1 },
contactName: { fontSize: 15, fontWeight: '500', color: colors.text },
contactEmail: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
addBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary,
borderRadius: radius.full,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight,
backgroundColor: colors.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
chipBarContent: {
paddingHorizontal: spacing.lg,
gap: spacing.sm,
alignItems: 'center',
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
chipActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
chipText: { ...typography.captionMedium, color: colors.textSecondary },
chipTextActive: { color: colors.primaryForeground },
chipDivider: {
width: 1,
height: 16,
backgroundColor: colors.border,
marginHorizontal: spacing.xs,
},
sectionHeader: {
backgroundColor: colors.background,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
sectionHeaderText: { ...typography.small, color: colors.primary },
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
errorBanner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
borderWidth: 1,
borderColor: colors.errorBorder,
},
errorText: { ...typography.caption, color: colors.errorForeground },
emptyState: {
alignItems: 'center',
paddingVertical: spacing.xxxl * 2,
gap: spacing.sm,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
-500
View File
@@ -1,500 +0,0 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
SectionList,
Pressable,
TextInput,
ActivityIndicator,
RefreshControl,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
import {
useContactsStore,
sortContactsByDisplayName,
selectGroupMembers,
type ContactCategory,
} from '../stores/contacts-store';
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
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';
type Nav = NativeStackNavigationProp<RootStackParamList>;
interface Section {
title: string;
data: ContactCard[];
}
function groupContacts(contacts: ContactCard[]): Section[] {
const sorted = sortContactsByDisplayName(contacts);
const groups: Record<string, ContactCard[]> = {};
for (const c of sorted) {
const name = getContactDisplayName(c).trim();
const letter = (name[0] || '#').toUpperCase();
const key = /[A-Z]/.test(letter) ? letter : '#';
if (!groups[key]) groups[key] = [];
groups[key].push(c);
}
return Object.keys(groups)
.sort((a, b) => {
if (a === '#') return 1;
if (b === '#') return -1;
return a.localeCompare(b);
})
.map((title) => ({ title, data: groups[title] }));
}
function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
switch (category.type) {
case 'all':
return 'All Contacts';
case 'addressBook':
return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
case 'group': {
const g = groups.find((c) => c.id === category.groupId);
return g ? getContactDisplayName(g) || 'Group' : 'Group';
}
case 'keyword':
return `#${category.keyword}`;
case 'uncategorized':
return 'Uncategorized';
default:
return 'Contacts';
}
}
export default function ContactsScreen() {
const navigation = useNavigation<Nav>();
const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks);
const loading = useContactsStore((s) => s.loading);
const error = useContactsStore((s) => s.error);
const selectedCategory = useContactsStore((s) => s.selectedCategory);
const hydrated = useContactsStore((s) => s.hydrated);
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const [searchQuery, setSearchQuery] = React.useState('');
const [searchActive, setSearchActive] = React.useState(false);
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [selection, setSelection] = React.useState<Set<string>>(new Set());
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
React.useEffect(() => {
void hydrate();
}, [hydrate]);
React.useEffect(() => {
void fetchAddressBooks();
void fetchContacts();
}, [fetchAddressBooks, fetchContacts]);
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const visible = React.useMemo(() => {
let filtered: ContactCard[];
switch (selectedCategory.type) {
case 'all':
filtered = individuals;
break;
case 'addressBook':
filtered = individuals.filter((c) => c.addressBookIds?.[selectedCategory.addressBookId]);
break;
case 'group':
filtered = selectGroupMembers(
{ contacts } as Parameters<typeof selectGroupMembers>[0],
selectedCategory.groupId,
).filter((c) => !isGroup(c));
break;
case 'keyword':
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
break;
case 'uncategorized':
filtered = individuals.filter(
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
);
break;
default:
filtered = individuals;
}
if (!searchQuery) return filtered;
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
}, [contacts, individuals, selectedCategory, searchQuery]);
const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible);
// Flat list — single unnamed section keeps SectionList rendering simple.
return [{ title: '', data: sortContactsByDisplayName(visible) }];
}, [visible, groupByLetter]);
const selectionMode = selection.size > 0;
const toggleSelect = (id: string) => {
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleRowPress = (c: ContactCard) => {
if (selectionMode) {
toggleSelect(c.id);
return;
}
if (isGroup(c)) {
navigation.navigate('GroupDetail', { groupId: c.id });
} else {
navigation.navigate('ContactDetail', { contactId: c.id });
}
};
const handleLongPress = (c: ContactCard) => {
toggleSelect(c.id);
};
const clearSelection = () => setSelection(new Set());
const doBulkDelete = async () => {
const ids = Array.from(selection);
setConfirmBulkDelete(false);
clearSelection();
await bulkDelete(ids);
};
const title = categoryTitle(selectedCategory, addressBooks, groups);
// Top chip row: All / per-address-book / Groups
const chips = React.useMemo(() => {
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
{
key: 'all',
label: 'All',
category: { type: 'all' },
active: selectedCategory.type === 'all',
},
];
for (const book of addressBooks) {
items.push({
key: `book:${book.id}`,
label: book.name,
category: { type: 'addressBook', addressBookId: book.id },
active: selectedCategory.type === 'addressBook' && selectedCategory.addressBookId === book.id,
});
}
if (groups.length > 0) {
items.push({
key: 'groups-divider',
label: 'Groups',
category: { type: 'all' },
active: false,
});
for (const g of groups) {
items.push({
key: `group:${g.id}`,
label: getContactDisplayName(g) || 'Group',
category: { type: 'group', groupId: g.id },
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
});
}
}
return items;
}, [addressBooks, groups, selectedCategory]);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
<View style={styles.header}>
{selectionMode ? (
<>
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
<X size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => setConfirmBulkDelete(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<Trash2 size={20} color={colors.error} />
</Pressable>
</View>
</>
) : (
<>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
<Menu size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
<Text style={styles.headerCount}>{visible.length}</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => setSearchActive((v) => !v)}
style={styles.headerIconBtn}
hitSlop={8}
>
<Search size={20} color={colors.text} />
</Pressable>
<Pressable
onPress={() => navigation.navigate('ContactForm', {})}
style={styles.addBtn}
hitSlop={8}
>
<Plus size={18} color={colors.primaryForeground} />
</Pressable>
</View>
</>
)}
</View>
{searchActive && (
<View style={styles.searchBar}>
<Search size={16} color={colors.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
<X size={16} color={colors.textMuted} />
</Pressable>
)}
</View>
)}
{!selectionMode && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.chipBar}
contentContainerStyle={styles.chipBarContent}
>
{chips.map((chip) =>
chip.key === 'groups-divider' ? (
<View key={chip.key} style={styles.chipDivider} />
) : (
<Pressable
key={chip.key}
onPress={() => setSelectedCategory(chip.category)}
style={[styles.chip, chip.active && styles.chipActive]}
>
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
{chip.label}
</Text>
</Pressable>
),
)}
</ScrollView>
)}
{error && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ContactListRow
contact={item}
onPress={() => handleRowPress(item)}
onLongPress={() => handleLongPress(item)}
selected={selection.has(item.id)}
selectionMode={selectionMode}
/>
)}
renderSectionHeader={({ section }) =>
section.title ? (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{section.title}</Text>
</View>
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
stickySectionHeadersEnabled
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={loading && hydrated}
onRefresh={() => {
void fetchContacts();
void fetchAddressBooks();
}}
tintColor={colors.primary}
/>
}
ListEmptyComponent={
loading ? (
<View style={styles.emptyState}>
<ActivityIndicator color={colors.primary} />
</View>
) : (
<View style={styles.emptyState}>
<UserCircle size={48} color={colors.surfaceActive} />
<Text style={styles.emptyTitle}>
{searchQuery ? 'No contacts found' : 'No contacts yet'}
</Text>
<Text style={styles.emptySubtitle}>
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
</Text>
</View>
)
}
/>
<Dialog
visible={confirmBulkDelete}
title="Delete contacts"
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
variant="destructive"
confirmText="Delete"
onConfirm={doBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
listContent: { paddingBottom: 80 },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
gap: spacing.sm,
},
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
headerCount: {
...typography.small,
color: colors.textMuted,
backgroundColor: colors.surface,
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
overflow: 'hidden',
},
headerActions: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
gap: spacing.sm,
},
headerIconBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
},
addBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary,
borderRadius: radius.full,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight,
backgroundColor: colors.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
chipBarContent: {
paddingHorizontal: spacing.lg,
gap: spacing.sm,
alignItems: 'center',
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
chipActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
chipText: { ...typography.captionMedium, color: colors.textSecondary },
chipTextActive: { color: colors.primaryForeground },
chipDivider: {
width: 1,
height: 16,
backgroundColor: colors.border,
marginHorizontal: spacing.xs,
},
sectionHeader: {
backgroundColor: colors.background,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
sectionHeaderText: { ...typography.small, color: colors.primary },
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
errorBanner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
borderWidth: 1,
borderColor: colors.errorBorder,
},
errorText: { ...typography.caption, color: colors.errorForeground },
emptyState: {
alignItems: 'center',
paddingVertical: spacing.xxxl * 2,
gap: spacing.sm,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
+113 -26
View File
@@ -9,7 +9,9 @@ import {
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
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 {
@@ -43,7 +45,7 @@ function formatRelativeTime(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function EmailRow({
const EmailRow = React.memo(function EmailRow({
item,
onPress,
onLongPress,
@@ -51,8 +53,8 @@ function EmailRow({
selectionMode,
}: {
item: Email;
onPress: () => void;
onLongPress: () => void;
onPress: (id: string) => void;
onLongPress: (id: string) => void;
selected: boolean;
selectionMode: boolean;
}) {
@@ -61,6 +63,9 @@ function EmailRow({
const unread = isUnread(item);
const starred = isStarred(item);
const handlePress = React.useCallback(() => onPress(item.id), [onPress, item.id]);
const handleLongPress = React.useCallback(() => onLongPress(item.id), [onLongPress, item.id]);
return (
<Pressable
style={({ pressed }) => [
@@ -68,8 +73,8 @@ function EmailRow({
pressed && styles.emailRowPressed,
selected && styles.emailRowSelected,
]}
onPress={onPress}
onLongPress={onLongPress}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
{selectionMode && (
@@ -117,7 +122,11 @@ function EmailRow({
</View>
</Pressable>
);
}
});
const EmailRowSeparator = () => <View style={styles.separator} />;
const emailKeyExtractor = (item: Email) => item.id;
interface EmailListScreenProps {
onEmailPress?: (email: Email) => void;
@@ -145,6 +154,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const markUnread = useEmailStore((s) => s.markUnread);
const toggleStar = useEmailStore((s) => s.toggleStar);
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
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],
);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
@@ -152,6 +178,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const allSelected =
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
// Refs so row press handlers stay referentially stable across renders.
// FlatList rows then skip re-render when the parent re-renders for unrelated
// reasons (e.g. opening the filter modal).
const onEmailPressRef = React.useRef(onEmailPress);
React.useEffect(() => { onEmailPressRef.current = onEmailPress; }, [onEmailPress]);
const selectionModeRef = React.useRef(selectionMode);
React.useEffect(() => { selectionModeRef.current = selectionMode; }, [selectionMode]);
const emailsRef = React.useRef(emails);
React.useEffect(() => { emailsRef.current = emails; }, [emails]);
const toggleSelect = React.useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -161,6 +197,68 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
});
}, []);
const handleRowPress = React.useCallback((id: string) => {
if (selectionModeRef.current) {
toggleSelect(id);
} else {
const email = emailsRef.current.find((e) => e.id === id);
if (email) onEmailPressRef.current?.(email);
}
}, [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 moveToMailboxAction(id, currentMailboxId, archiveMailboxId);
}
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);
break;
}
}, [
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar,
]);
const renderEmailRow = React.useCallback(
({ item }: { item: Email }) => (
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
context={{ unread: isUnread(item), starred: isStarred(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, swipeLeftAction, swipeRightAction, handleSwipeAction],
);
const clearSelection = React.useCallback(() => {
setSelectedIds(new Set());
}, []);
@@ -488,20 +586,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
) : (
<FlatList
data={emails}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={() => {
if (selectionMode) toggleSelect(item.id);
else onEmailPress?.(item);
}}
onLongPress={() => toggleSelect(item.id)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
keyExtractor={emailKeyExtractor}
renderItem={renderEmailRow}
ItemSeparatorComponent={EmailRowSeparator}
contentContainerStyle={styles.listContent}
onEndReached={() => { void loadMoreEmails(); }}
onEndReachedThreshold={0.3}
@@ -510,7 +597,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
)}
{/* Compose FAB — matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
<Pressable
onPress={onComposePress}
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
@@ -749,7 +836,7 @@ function TriToggle({
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
// Header — matches web mobile-header: h-14 (56px), px-4, border-b
// Header - matches web mobile-header: h-14 (56px), px-4, border-b
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -770,7 +857,7 @@ const styles = StyleSheet.create({
height: 28,
},
// Search — matches web search bar styling
// Search - matches web search bar styling
searchBar: {
flexDirection: 'row',
alignItems: 'center',
@@ -1026,7 +1113,7 @@ const styles = StyleSheet.create({
color: colors.primary,
},
// Email list — matches web email-list-item
// Email list - matches web email-list-item
listContent: { paddingBottom: 100 },
emailRow: {
flexDirection: 'row',
@@ -1111,7 +1198,7 @@ const styles = StyleSheet.create({
tagText: { ...typography.small, fontWeight: '500' },
separator: { height: 0 }, // borders are on rows now
// Compose FAB — matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
// Compose FAB - matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
fab: {
position: 'absolute',
right: spacing.lg, // right-4
@@ -1119,7 +1206,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: colors.text, // white - matches webmail mobile FAB
alignItems: 'center',
justifyContent: 'center',
elevation: 6,
-106
View File
@@ -1,106 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList, Pressable } from 'react-native';
import { Mail, Star } from 'lucide-react-native';
import { colors } from '../theme/colors';
const MOCK_EMAILS = [
{ id: '1', from: 'Alice Johnson', subject: 'Project update for Q2', preview: 'Hi, I wanted to share the latest progress on our Q2 deliverables...', date: '10:30 AM', unread: true, starred: true },
{ id: '2', from: 'Bob Smith', subject: 'Re: Meeting tomorrow', preview: 'Sounds good! I\'ll bring the presentation slides...', date: '9:15 AM', unread: true, starred: false },
{ id: '3', from: 'Carol Davis', subject: 'Invoice #1234', preview: 'Please find attached the invoice for March services...', date: 'Yesterday', unread: false, starred: false },
{ id: '4', from: 'David Wilson', subject: 'Welcome to the team!', preview: 'We\'re excited to have you on board. Here are some resources...', date: 'Yesterday', unread: false, starred: true },
{ id: '5', from: 'Emma Brown', subject: 'Vacation request', preview: 'I\'d like to request time off from April 15-22...', date: 'Mar 27', unread: false, starred: false },
{ id: '6', from: 'Frank Miller', subject: 'Security alert', preview: 'We detected a new sign-in to your account from...', date: 'Mar 26', unread: false, starred: false },
{ id: '7', from: 'Grace Lee', subject: 'Newsletter: March Highlights', preview: 'Here\'s what happened this month in our community...', date: 'Mar 25', unread: false, starred: false },
{ id: '8', from: 'Henry Taylor', subject: 'Re: Code review', preview: 'Great work on the refactor! I left a few comments...', date: 'Mar 24', unread: false, starred: false },
];
function EmailRow({ item }: { item: typeof MOCK_EMAILS[0] }) {
return (
<Pressable style={({ pressed }) => [styles.emailRow, pressed && styles.emailRowPressed]}>
<View style={styles.emailLeft}>
<View style={[styles.avatar, { backgroundColor: item.unread ? colors.primary : colors.surfaceHover }]}>
<Text style={[styles.avatarText, { color: item.unread ? colors.textInverse : colors.textSecondary }]}>
{item.from.charAt(0)}
</Text>
</View>
</View>
<View style={styles.emailContent}>
<View style={styles.emailHeader}>
<Text style={[styles.emailFrom, item.unread && styles.emailUnread]} numberOfLines={1}>
{item.from}
</Text>
<Text style={styles.emailDate}>{item.date}</Text>
</View>
<Text style={[styles.emailSubject, item.unread && styles.emailUnread]} numberOfLines={1}>
{item.subject}
</Text>
<Text style={styles.emailPreview} numberOfLines={1}>
{item.preview}
</Text>
</View>
{item.starred && (
<Star size={16} color={colors.starred} fill={colors.starred} style={styles.starIcon} />
)}
</Pressable>
);
}
export default function EmailScreen() {
return (
<View style={styles.container}>
<View style={styles.header}>
<Mail size={24} color={colors.primary} />
<Text style={styles.headerTitle}>Inbox</Text>
<Text style={styles.headerCount}>2 unread</Text>
</View>
<FlatList
data={MOCK_EMAILS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <EmailRow item={item} />}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.listContent}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: 8,
},
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 },
headerCount: { fontSize: 13, color: colors.textSecondary },
listContent: { paddingBottom: 20 },
emailRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
},
emailRowPressed: { backgroundColor: colors.surfaceHover },
emailLeft: { marginRight: 12 },
avatar: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: { fontSize: 16, fontWeight: '600' },
emailContent: { flex: 1 },
emailHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 },
emailFrom: { fontSize: 15, color: colors.text, flex: 1, marginRight: 8 },
emailDate: { fontSize: 12, color: colors.textMuted },
emailSubject: { fontSize: 14, color: colors.text, marginBottom: 2 },
emailPreview: { fontSize: 13, color: colors.textSecondary },
emailUnread: { fontWeight: '700' },
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
starIcon: { marginLeft: 8 },
});
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, ActivityIndicator, Pressable, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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';
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
export default function EmailSourceScreen({ route, navigation }: Props) {
const { blobId, subject } = route.params;
const [raw, setRaw] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const text = await fetchRawEmail(blobId);
if (!cancelled) setRaw(text);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
}
})();
return () => { cancelled = true; };
}, [blobId]);
const onShare = async () => {
try {
await shareEmailEml(blobId, subject);
} catch (e) {
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
}
};
return (
<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} />
</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} />
</Pressable>
</View>
{!raw && !error ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary} />
</View>
) : error ? (
<View style={styles.loading}>
<Text style={styles.error}>{error}</Text>
</View>
) : (
<ScrollView contentContainerStyle={styles.body}>
<Text selectable style={styles.source}>{raw}</Text>
</ScrollView>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
error: { ...typography.body, color: colors.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
body: { padding: spacing.lg },
source: {
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 16,
color: colors.text,
},
});
+603 -15
View File
@@ -1,20 +1,39 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Paperclip,
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, Tag,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { useEmailStore } from '../stores/email-store';
import type { Email } from '../api/types';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
}
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
function formatHeaderDate(iso: string): string {
@@ -43,16 +62,36 @@ function plainTextBody(email: Email): string {
export default function EmailThreadScreen({ route, navigation }: Props) {
const { emailId, subject: subjectParam } = route.params;
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
const markRead = useEmailStore((s) => s.markRead);
const toggleStar = useEmailStore((s) => s.toggleStar);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
const currentIndex = emails.findIndex((e) => e.id === emailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const goToEmail = (target: { id: string; threadId: string; subject: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject,
});
};
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
React.useEffect(() => {
let cancelled = false;
@@ -78,16 +117,55 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
}, [emailId, getEmailDetail, markRead]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
const archiveMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive'),
[mailboxes],
);
const junkMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
[mailboxes],
);
const inboxMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'inbox'),
[mailboxes],
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const updateLocalKeywords = (next: Record<string, boolean>) => {
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
};
const onToggleKeyword = (token: string) => {
if (!email) return;
const next = { ...email.keywords };
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
};
const onToggleStar = () => {
if (!email) return;
void toggleStar(email.id, !starred);
setEmail({
...email,
keywords: {
...email.keywords,
...(starred ? {} : { $flagged: true }),
},
});
const next = { ...email.keywords };
if (starred) delete next.$flagged;
else next.$flagged = true;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
};
const onToggleUnread = () => {
if (!email) return;
if (unread) {
void markRead(email.id);
updateLocalKeywords({ ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
}
};
const onDelete = () => {
@@ -98,6 +176,35 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
navigation.goBack();
};
const onArchive = () => {
if (!email || !currentMailboxId || !archiveMailbox) return;
if (currentMailboxId === archiveMailbox.id) return;
void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id);
navigation.goBack();
};
const onToggleSpam = () => {
if (!email || !currentMailboxId) return;
setMoreMenuOpen(false);
if (isInJunk) {
const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId);
if (!target) return;
void moveToMailbox(email.id, currentMailboxId, target.id);
} else {
if (!junkMailbox) return;
void moveToMailbox(email.id, currentMailboxId, junkMailbox.id);
}
navigation.goBack();
};
const onMoveToMailbox = (toId: string) => {
if (!email || !currentMailboxId || toId === currentMailboxId) return;
setMoveMenuOpen(false);
setMoreMenuOpen(false);
void moveToMailbox(email.id, currentMailboxId, toId);
navigation.goBack();
};
const navigateCompose = (mode: 'reply' | 'replyAll' | 'forward') => {
if (!email) return;
const from = email.from?.[0];
@@ -119,6 +226,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const from = email?.from?.[0];
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
// back arrow + side padding reserves ~64px. Drop optional buttons on narrow screens.
const showMarkUnread = windowWidth >= 340;
const showArchive = windowWidth >= 400 && !!archiveMailbox;
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Toolbar */}
@@ -132,6 +244,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
label="Delete"
onPress={onDelete}
/>
{showArchive && (
<ToolbarButton
icon={<Archive size={18} color={colors.textSecondary} />}
label="Archive"
onPress={onArchive}
/>
)}
{showMarkUnread && (
<ToolbarButton
icon={
unread ? (
<MailOpen size={18} color={colors.textSecondary} />
) : (
<Mail size={18} color={colors.textSecondary} />
)
}
label={unread ? 'Read' : 'Unread'}
onPress={onToggleUnread}
/>
)}
<ToolbarButton
icon={
<Star
@@ -146,6 +278,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ToolbarButton
icon={<MoreVertical size={18} color={colors.textSecondary} />}
label="More"
onPress={() => setMoreMenuOpen(true)}
/>
</View>
</View>
@@ -203,7 +336,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
) : null}
<Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text>
{email.to?.map((t) => t.name || t.email).join(', ') || '—'}
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
</Text>
</View>
</View>
@@ -237,7 +370,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<BottomBarButton
icon={<ChevronLeft size={20} color={colors.textMuted} />}
label="Prev"
disabled
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
disabled={!prevEmail}
/>
<BottomBarButton
icon={<Reply size={20} color={colors.textSecondary} />}
@@ -257,15 +391,379 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<BottomBarButton
icon={<ChevronRight size={20} color={colors.textMuted} />}
label="Next"
disabled
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
disabled={!nextEmail}
/>
</View>
</>
) : null}
<MoreMenuSheet
visible={moreMenuOpen}
onClose={() => setMoreMenuOpen(false)}
unread={unread}
canArchive={!!archiveMailbox && currentMailboxId !== archiveMailbox?.id}
canMarkUnread={true}
canMove={mailboxes.length > 0}
showSpam={!!junkMailbox || isInJunk}
isInJunk={isInJunk}
canViewSource={!!email?.blobId}
canExport={!!email?.blobId}
canTag={keywordDefs.length > 0}
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
onTag={() => { setMoreMenuOpen(false); setTagMenuOpen(true); }}
onToggleSpam={onToggleSpam}
onViewSource={() => {
setMoreMenuOpen(false);
if (email?.blobId) {
navigation.navigate('EmailSource', {
emailId: email.id,
blobId: email.blobId,
subject: email.subject,
});
}
}}
onExport={async () => {
setMoreMenuOpen(false);
if (!email?.blobId) return;
try {
await shareEmailEml(email.blobId, email.subject);
} catch (e) {
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
}
}}
/>
<MoveMenuSheet
visible={moveMenuOpen}
onClose={() => setMoveMenuOpen(false)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
onPick={onMoveToMailbox}
/>
<TagMenuSheet
visible={tagMenuOpen}
onClose={() => setTagMenuOpen(false)}
keywords={keywordDefs}
activeKeywords={email?.keywords ?? {}}
onToggle={onToggleKeyword}
/>
</SafeAreaView>
);
}
interface MoreMenuSheetProps {
visible: boolean;
onClose: () => void;
unread: boolean;
canArchive: boolean;
canMarkUnread: boolean;
canMove: boolean;
canTag: boolean;
showSpam: boolean;
isInJunk: boolean;
canViewSource: boolean;
canExport: boolean;
onArchive: () => void;
onToggleUnread: () => void;
onMove: () => void;
onTag: () => void;
onToggleSpam: () => void;
onViewSource: () => void;
onExport: () => void;
}
function MoreMenuSheet({
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
showSpam, isInJunk, canViewSource, canExport,
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onViewSource, onExport,
}: MoreMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(400)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 400, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
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 style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>More actions</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
</View>
{canArchive && (
<MoreMenuItem
icon={<Archive size={18} color={colors.textSecondary} />}
label="Archive"
onPress={onArchive}
/>
)}
{canMarkUnread && (
<MoreMenuItem
icon={
unread ? (
<MailOpen size={18} color={colors.textSecondary} />
) : (
<Mail size={18} color={colors.textSecondary} />
)
}
label={unread ? 'Mark as read' : 'Mark as unread'}
onPress={onToggleUnread}
/>
)}
{canMove && (
<MoreMenuItem
icon={<FolderInput size={18} color={colors.textSecondary} />}
label="Move to folder…"
onPress={onMove}
trailing={<ChevronRight size={16} color={colors.textMuted} />}
/>
)}
{canTag && (
<MoreMenuItem
icon={<Tag size={18} color={colors.textSecondary} />}
label="Tag…"
onPress={onTag}
trailing={<ChevronRight size={16} color={colors.textMuted} />}
/>
)}
{showSpam && (
<MoreMenuItem
icon={
isInJunk ? (
<ShieldCheck size={18} color={colors.success} />
) : (
<ShieldAlert size={18} color={colors.error} />
)
}
label={isInJunk ? 'Not spam' : 'Mark as spam'}
onPress={onToggleSpam}
/>
)}
{canViewSource && (
<MoreMenuItem
icon={<Code size={18} color={colors.textSecondary} />}
label="View source"
onPress={onViewSource}
/>
)}
{canExport && (
<MoreMenuItem
icon={<Download size={18} color={colors.textSecondary} />}
label="Export email (.eml)"
onPress={onExport}
/>
)}
</Animated.View>
</Modal>
);
}
interface TagMenuSheetProps {
visible: boolean;
onClose: () => void;
keywords: KeywordDef[];
activeKeywords: Record<string, boolean>;
onToggle: (token: string) => void;
}
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
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 style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Tags</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
</View>
{keywords.length === 0 ? (
<Text style={{ ...typography.body, color: colors.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
No tags yet. Add some in Settings → Keywords & Labels.
</Text>
) : (
keywords.map((kw) => {
const token = keywordToken(kw.id);
const active = !!activeKeywords[token];
const palette = colors.tags[kw.color];
return (
<MoreMenuItem
key={kw.id}
icon={<View style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: palette.dot }} />}
label={kw.label}
onPress={() => onToggle(token)}
trailing={active ? <Check size={16} color={colors.primary} /> : null}
/>
);
})
)}
</Animated.View>
</Modal>
);
}
interface MoveMenuSheetProps {
visible: boolean;
onClose: () => void;
mailboxes: Mailbox[];
currentMailboxId: string | null;
onPick: (id: string) => void;
}
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const visibleNodes = React.useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const expanded = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (n.children.length > 0) {
expanded.add(n.id);
collect(n.children);
}
}
};
collect(tree);
return flattenVisible(tree, expanded);
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
styles.sheetTall,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? colors.textSecondary : colors.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={colors.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function MoreMenuItem({
icon, label, onPress, trailing,
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.moreItem, pressed && styles.moreItemPressed]}
>
<View style={styles.moreItemIcon}>{icon}</View>
<Text style={styles.moreItemLabel}>{label}</Text>
{trailing}
</Pressable>
);
}
function ToolbarButton({
icon, label, onPress,
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
@@ -324,7 +822,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
gap: 2,
paddingHorizontal: spacing.md,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
minHeight: 44,
},
@@ -487,4 +985,94 @@ const styles = StyleSheet.create({
bottomBarLabelDisabled: {
color: colors.textMuted,
},
// Bottom sheet (More menu / Move folder picker)
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: colors.border,
paddingTop: spacing.sm,
},
sheetTall: {
maxHeight: '75%',
},
sheetHandle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
marginBottom: spacing.sm,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
sheetTitle: {
...typography.bodySemibold,
color: colors.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
// More menu item
moreItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 48,
},
moreItemPressed: { backgroundColor: colors.surfaceHover },
moreItemIcon: {
width: 20,
alignItems: 'center',
justifyContent: 'center',
},
moreItemLabel: {
...typography.body,
color: colors.text,
flex: 1,
},
// Move folder row
moveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: colors.surfaceHover },
moveRowLabel: {
...typography.body,
color: colors.text,
flex: 1,
},
moveRowLabelDisabled: {
color: colors.textMuted,
},
});
+384 -107
View File
@@ -1,143 +1,420 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
import { Settings, User, Palette, Bell, Shield, ChevronRight } from 'lucide-react-native';
import { colors } from '../theme/colors';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, BackHandler } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
ArrowLeft, LogOut, Settings, ChevronRight,
Palette, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
type LucideIcon,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { ReadingSettings } from '../components/settings/ReadingSettings';
import { NotificationSettings } from '../components/settings/NotificationSettings';
import { AccountSettings } from '../components/settings/AccountSettings';
import { IdentitySettings } from '../components/settings/IdentitySettings';
import { VacationSettings } from '../components/settings/VacationSettings';
import { FolderSettings } from '../components/settings/FolderSettings';
import { AboutDataSettings } from '../components/settings/AboutDataSettings';
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
import { AppearanceSettings } from '../components/settings/AppearanceSettings';
import { CalendarSettings } from '../components/settings/CalendarSettings';
import { ContactsSettings } from '../components/settings/ContactsSettings';
import { FilesSettings } from '../components/settings/FilesSettings';
import { FilterSettings } from '../components/settings/FilterSettings';
import { TemplateSettings } from '../components/settings/TemplateSettings';
import { KeywordSettings } from '../components/settings/KeywordSettings';
import { AccountSecuritySettings } from '../components/settings/AccountSecuritySettings';
import { SmimeSettings } from '../components/settings/SmimeSettings';
import { SidebarAppsSettings } from '../components/settings/SidebarAppsSettings';
import { ThemesSettings } from '../components/settings/ThemesSettings';
import { PluginsSettings } from '../components/settings/PluginsSettings';
import { ContentSendersSettings } from '../components/settings/ContentSendersSettings';
import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
interface SettingItemProps {
icon: React.ReactNode;
title: string;
subtitle?: string;
rightElement?: React.ReactNode;
type Tab =
| 'account' | 'language' | 'notifications'
| 'appearance' | 'layout'
| 'reading' | 'composing' | 'identities' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords'
| 'security' | 'encryption' | 'content_senders'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
| 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
experimental?: boolean;
implemented: boolean;
}
function SettingItem({ icon, title, subtitle, rightElement }: SettingItemProps) {
return (
<Pressable style={({ pressed }) => [styles.settingRow, pressed && styles.settingRowPressed]}>
<View style={styles.settingIcon}>{icon}</View>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>{title}</Text>
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
</View>
{rightElement || <ChevronRight size={18} color={colors.textMuted} />}
</Pressable>
);
const GROUP_LABELS: Record<TabGroup, string> = {
general: 'General',
appearance: 'Appearance',
mail: 'Mail',
privacy: 'Privacy & Security',
apps: 'Apps',
advanced: 'Advanced',
};
const GROUP_ORDER: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
const TABS: TabDef[] = [
// General
{ id: 'account', label: 'Account', icon: User, group: 'general', implemented: true },
{ id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: true },
{ id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true },
// Appearance
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: true },
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: true },
// Mail
{ id: 'reading', label: 'Reading', icon: BookOpen, group: 'mail', implemented: true },
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: true },
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: true },
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: true },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true },
// Privacy & Security
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: true },
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: true },
// Apps
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: true },
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: true },
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: true },
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: true },
// Advanced
{ id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', experimental: true, implemented: true },
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: true },
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true },
{ id: 'debug', label: 'Debug', icon: Bug, group: 'advanced', implemented: false },
];
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
account: AccountSettings,
language: LanguageSettings,
notifications: NotificationSettings,
appearance: AppearanceSettings,
layout: LayoutSettings,
reading: ReadingSettings,
composing: ComposingSettings,
identities: IdentitySettings,
vacation: VacationSettings,
filters: FilterSettings,
templates: TemplateSettings,
folders: FolderSettings,
keywords: KeywordSettings,
security: AccountSecuritySettings,
encryption: SmimeSettings,
content_senders: ContentSendersSettings,
calendar: CalendarSettings,
contacts: ContactsSettings,
files: FilesSettings,
sidebar_apps: SidebarAppsSettings,
about_data: AboutDataSettings,
themes: ThemesSettings,
plugins: PluginsSettings,
updates: UpdatesSettings,
};
function groupTabs() {
return GROUP_ORDER.map(group => ({
group,
label: GROUP_LABELS[group],
items: TABS.filter(t => t.group === group),
})).filter(g => g.items.length > 0);
}
export default function SettingsScreen() {
const [darkMode, setDarkMode] = React.useState(false);
const [notifications, setNotifications] = React.useState(true);
interface SettingsScreenProps {
onLogout?: () => void;
onBack?: () => void;
onTabSelect?: (tab: Tab) => void;
}
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
const groupedTabs = groupTabs();
useEffect(() => {
if (!selectedTab) return;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
setSelectedTab(null);
return true;
});
return () => sub.remove();
}, [selectedTab]);
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
setSelectedTab(tab.id);
onTabSelect?.(tab.id);
};
if (selectedTab) {
const tabDef = TABS.find(t => t.id === selectedTab)!;
const Component = TAB_COMPONENTS[selectedTab];
const TabIcon = tabDef.icon;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable
onPress={() => setSelectedTab(null)}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<TabIcon size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>{tabDef.label}</Text>
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
{Component ? <Component /> : null}
</ScrollView>
</SafeAreaView>
);
}
return (
<ScrollView style={styles.container}>
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
<View style={styles.header}>
<Settings size={24} color={colors.primary} />
<Pressable
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<Settings size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>Settings</Text>
</View>
<View style={styles.profileCard}>
<View style={styles.profileAvatar}>
<Text style={styles.profileAvatarText}>B</Text>
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
<View style={styles.tabList}>
{groupedTabs.map((group, groupIndex) => (
<View key={group.group}>
{groupIndex > 0 && <View style={styles.groupDivider} />}
<View style={styles.groupHeader}>
<Text style={styles.groupLabel}>{group.label}</Text>
</View>
{group.items.map((tab) => {
const Icon = tab.icon;
const disabled = !tab.implemented;
return (
<Pressable
key={tab.id}
onPress={() => handleTabPress(tab)}
disabled={disabled}
style={({ pressed }) => [
styles.tabItem,
disabled && styles.tabItemDisabled,
!disabled && pressed && styles.tabItemPressed,
]}
>
<View style={styles.tabItemLeft}>
<Icon size={16} color={colors.mutedForeground} />
<Text
style={[
styles.tabItemLabel,
disabled && styles.tabItemLabelDisabled,
]}
>
{tab.label}
</Text>
{tab.experimental && !disabled && (
<View style={styles.experimentalBadge}>
<Text style={styles.experimentalText}>Experimental</Text>
</View>
)}
</View>
{disabled ? (
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not implemented</Text>
</View>
) : (
<ChevronRight size={16} color={colors.mutedForeground} />
)}
</Pressable>
);
})}
</View>
))}
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>Bulwark User</Text>
<Text style={styles.profileEmail}>user@example.com</Text>
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
<View style={styles.logoutSection}>
<Pressable
onPress={onLogout}
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
>
<LogOut size={16} color={colors.error} />
<Text style={styles.logoutText}>Sign Out</Text>
</Pressable>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<SettingItem
icon={<User size={20} color={colors.primary} />}
title="Account Settings"
subtitle="Manage your account details"
/>
<SettingItem
icon={<Shield size={20} color={colors.primary} />}
title="Privacy & Security"
subtitle="Passwords, encryption, trusted senders"
/>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Preferences</Text>
<SettingItem
icon={<Palette size={20} color={colors.primary} />}
title="Dark Mode"
rightElement={<Switch value={darkMode} onValueChange={setDarkMode} trackColor={{ true: colors.primary }} />}
/>
<SettingItem
icon={<Bell size={20} color={colors.primary} />}
title="Notifications"
rightElement={<Switch value={notifications} onValueChange={setNotifications} trackColor={{ true: colors.primary }} />}
/>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
<Text style={styles.footerText}>Built with Expo + React Native</Text>
</View>
</ScrollView>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
container: {
flex: 1,
backgroundColor: colors.background,
},
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: 8,
gap: spacing.sm,
},
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
profileCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
margin: 16,
backgroundColor: colors.surface,
borderRadius: 12,
},
profileAvatar: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary,
headerBackBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
borderRadius: radius.md,
},
profileAvatarText: { fontSize: 24, fontWeight: '700', color: colors.textInverse },
profileInfo: { flex: 1 },
profileName: { fontSize: 18, fontWeight: '600', color: colors.text },
profileEmail: { fontSize: 14, color: colors.textSecondary, marginTop: 2 },
section: { marginTop: 8 },
sectionTitle: {
fontSize: 13,
headerBackBtnPressed: {
backgroundColor: colors.accent,
},
headerTitle: {
...typography.h3,
color: colors.text,
},
scrollArea: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
detailContent: {
padding: spacing.lg,
paddingBottom: 40,
},
tabList: {
paddingVertical: spacing.sm,
},
// Group divider - webmail: mx-5 my-2 border-t border-border
groupDivider: {
height: 1,
backgroundColor: colors.border,
marginHorizontal: 20,
marginVertical: spacing.sm,
},
// Group header - webmail: px-5 pt-3 pb-1.5
groupHeader: {
paddingHorizontal: 20,
paddingTop: spacing.md,
paddingBottom: 6,
},
groupLabel: {
fontSize: 11,
fontWeight: '600',
color: colors.textSecondary,
paddingHorizontal: 16,
paddingVertical: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.8,
color: colors.mutedForeground,
},
settingRow: {
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
tabItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 14,
},
settingRowPressed: { backgroundColor: colors.surfaceHover },
settingIcon: { marginRight: 14 },
settingContent: { flex: 1 },
settingTitle: { fontSize: 16, color: colors.text },
settingSubtitle: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
footer: {
tabItemPressed: {
backgroundColor: colors.muted,
},
tabItemLeft: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 32,
gap: 4,
gap: spacing.md,
flex: 1,
},
tabItemLabel: {
...typography.body,
color: colors.text,
},
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
experimentalBadge: {
backgroundColor: 'rgba(202, 138, 4, 0.15)',
borderRadius: radius.full,
paddingHorizontal: 6,
paddingVertical: 2,
},
experimentalText: {
fontSize: 10,
fontWeight: '500',
color: colors.warning,
},
tabItemDisabled: {
opacity: 0.55,
},
tabItemLabelDisabled: {
color: colors.mutedForeground,
},
notWorkingBadge: {
backgroundColor: colors.muted,
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
},
notWorkingText: {
fontSize: 10,
fontWeight: '500',
color: colors.mutedForeground,
},
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
logoutSection: {
borderTopWidth: 1,
borderTopColor: colors.border,
paddingHorizontal: 20,
paddingVertical: spacing.md,
},
logoutBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: 10,
paddingHorizontal: spacing.sm,
borderRadius: radius.sm,
},
logoutBtnPressed: {
backgroundColor: colors.muted,
},
logoutText: {
...typography.body,
color: colors.error,
},
footerText: { fontSize: 12, color: colors.textMuted },
});
-250
View File
@@ -1,250 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
User, Mail, CalendarDays, Shield, Bell, Palette, Globe, Lock,
ChevronRight, LogOut, Moon, Server, Key, Fingerprint, Info, HelpCircle
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
interface SettingItem {
id: string;
icon: any;
iconColor: string;
iconBg: string;
label: string;
detail?: string;
type: 'link' | 'toggle';
value?: boolean;
}
const PROFILE = {
name: 'Jane Doe',
email: 'jane@bulwark.mail',
plan: 'Pro',
};
const SECTIONS: { title: string; items: SettingItem[] }[] = [
{
title: 'Account',
items: [
{ id: 'profile', icon: User, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Profile', detail: PROFILE.email, type: 'link' },
{ id: 'identities', icon: Mail, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Identities & Signatures', type: 'link' },
{ id: 'security', icon: Lock, iconColor: colors.error, iconBg: colors.errorBg, label: 'Security', detail: '2FA enabled', type: 'link' },
],
},
{
title: 'Email',
items: [
{ id: 'filters', icon: Mail, iconColor: colors.calendar.teal, iconBg: colors.calendar.tealBg, label: 'Filters & Rules', type: 'link' },
{ id: 'smime', icon: Shield, iconColor: colors.calendar.indigo, iconBg: colors.calendar.indigoBg, label: 'S/MIME Encryption', type: 'link' },
{ id: 'vacation', icon: CalendarDays, iconColor: colors.calendar.orange, iconBg: colors.warningBg, label: 'Vacation Responder', detail: 'Off', type: 'link' },
],
},
{
title: 'Appearance',
items: [
{ id: 'theme', icon: Palette, iconColor: colors.calendar.pink, iconBg: colors.calendar.pinkBg, label: 'Theme', detail: 'System', type: 'link' },
{ id: 'darkMode', icon: Moon, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Dark Mode', type: 'toggle', value: false },
{ id: 'language', icon: Globe, iconColor: colors.calendar.blue, iconBg: colors.primaryBg, label: 'Language', detail: 'English', type: 'link' },
],
},
{
title: 'Notifications',
items: [
{ id: 'pushNotif', icon: Bell, iconColor: colors.calendar.green, iconBg: colors.successBg, label: 'Push Notifications', type: 'toggle', value: true },
{ id: 'emailNotif', icon: Mail, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Email Notifications', type: 'toggle', value: true },
],
},
{
title: 'Advanced',
items: [
{ id: 'server', icon: Server, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Server Settings', type: 'link' },
{ id: 'privacy', icon: Fingerprint, iconColor: colors.textSecondary, iconBg: colors.surface, label: 'Privacy', type: 'link' },
{ id: 'about', icon: Info, iconColor: colors.textMuted, iconBg: colors.surface, label: 'About', detail: 'v1.0.0', type: 'link' },
{ id: 'help', icon: HelpCircle, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Help & Support', type: 'link' },
],
},
];
function SettingRow({ item }: { item: SettingItem }) {
const Icon = item.icon;
const [toggled, setToggled] = React.useState(item.value ?? false);
return (
<Pressable
style={({ pressed }) => [styles.settingRow, pressed && item.type === 'link' && styles.settingRowPressed]}
>
<View style={[styles.settingIcon, { backgroundColor: item.iconBg }]}>
<Icon size={18} color={item.iconColor} />
</View>
<View style={styles.settingContent}>
<Text style={styles.settingLabel}>{item.label}</Text>
{item.detail && item.type === 'link' && (
<Text style={styles.settingDetail}>{item.detail}</Text>
)}
</View>
{item.type === 'link' && (
<ChevronRight size={18} color={colors.textMuted} />
)}
{item.type === 'toggle' && (
<Switch
value={toggled}
onValueChange={setToggled}
trackColor={{ false: colors.surface, true: colors.primary }}
thumbColor={colors.background}
/>
)}
</Pressable>
);
}
interface SettingsScreenProps {
onLogout?: () => void;
}
export default function SettingsScreen({ onLogout }: SettingsScreenProps) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Profile card */}
<View style={styles.profileCard}>
<View style={styles.profileAvatar}>
<Text style={styles.profileAvatarText}>JD</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{PROFILE.name}</Text>
<Text style={styles.profileEmail}>{PROFILE.email}</Text>
</View>
<View style={styles.planBadge}>
<Text style={styles.planBadgeText}>{PROFILE.plan}</Text>
</View>
</View>
{/* Settings sections */}
{SECTIONS.map(section => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<View style={styles.sectionCard}>
{section.items.map((item, idx) => (
<React.Fragment key={item.id}>
{idx > 0 && <View style={styles.sectionSeparator} />}
<SettingRow item={item} />
</React.Fragment>
))}
</View>
</View>
))}
{/* Sign out */}
<Pressable style={styles.signOutBtn} onPress={onLogout}>
<LogOut size={18} color={colors.error} />
<Text style={styles.signOutText}>Sign Out</Text>
</Pressable>
{/* Version info */}
<Text style={styles.versionText}>Bulwark Mobile v0.0.1</Text>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
scrollContent: { paddingBottom: 40 },
// Profile card
profileCard: {
flexDirection: 'row',
alignItems: 'center',
margin: spacing.lg,
padding: spacing.lg,
backgroundColor: colors.surface,
borderRadius: radius.xl,
gap: spacing.md,
},
profileAvatar: {
width: componentSizes.avatarLg, height: componentSizes.avatarLg,
borderRadius: componentSizes.avatarLg / 2,
backgroundColor: colors.primary,
alignItems: 'center', justifyContent: 'center',
},
profileAvatarText: { ...typography.h3, color: colors.textInverse },
profileInfo: { flex: 1 },
profileName: { ...typography.bodyMedium, color: colors.text },
profileEmail: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
planBadge: {
backgroundColor: colors.primaryBg,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: radius.full,
},
planBadgeText: { ...typography.small, color: colors.primary },
// Sections
section: {
marginHorizontal: spacing.lg,
marginBottom: spacing.xl,
},
sectionTitle: {
...typography.small,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
sectionCard: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
overflow: 'hidden',
},
sectionSeparator: {
height: 1,
backgroundColor: colors.borderLight,
marginLeft: 56,
},
// Setting row
settingRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
gap: spacing.md,
},
settingRowPressed: { backgroundColor: colors.surfaceHover },
settingIcon: {
width: 32, height: 32,
borderRadius: radius.sm,
alignItems: 'center', justifyContent: 'center',
},
settingContent: { flex: 1 },
settingLabel: { ...typography.body, color: colors.text },
settingDetail: { ...typography.small, color: colors.textMuted, marginTop: 1 },
// Sign out
signOutBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.errorBorder,
backgroundColor: colors.errorBg,
},
signOutText: { ...typography.bodyMedium, color: colors.error },
// Version
versionText: {
...typography.small,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
});
-356
View File
@@ -1,356 +0,0 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
ArrowLeft, LogOut, Settings, ChevronRight,
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle,
type LucideIcon,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { EmailSettings } from '../components/settings/EmailSettings';
import { NotificationSettings } from '../components/settings/NotificationSettings';
import { AccountSettings } from '../components/settings/AccountSettings';
import { IdentitySettings } from '../components/settings/IdentitySettings';
import { VacationSettings } from '../components/settings/VacationSettings';
import { FolderSettings } from '../components/settings/FolderSettings';
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
type Tab =
| 'appearance' | 'email' | 'notifications'
| 'account' | 'security' | 'identities' | 'encryption' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
| 'themes' | 'plugins' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
experimental?: boolean;
implemented: boolean;
}
const GROUP_LABELS: Record<TabGroup, string> = {
general: 'General',
account: 'Account',
organization: 'Organization',
apps: 'Apps',
system: 'System',
};
const GROUP_ORDER: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
const TABS: TabDef[] = [
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'general', implemented: false },
{ id: 'email', label: 'Email', icon: Mail, group: 'general', implemented: true },
{ id: 'notifications',label: 'Notifications', icon: Bell, group: 'general', implemented: true },
{ id: 'account', label: 'Account', icon: User, group: 'account', implemented: true },
{ id: 'security', label: 'Security', icon: Shield, group: 'account', implemented: false },
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'account', implemented: true },
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account', implemented: false },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account', implemented: true },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization', implemented: false },
{ id: 'templates', label: 'Templates', icon: FileText, group: 'organization', implemented: false },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization', implemented: true },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization', implemented: false },
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
];
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
email: EmailSettings,
notifications: NotificationSettings,
account: AccountSettings,
identities: IdentitySettings,
vacation: VacationSettings,
folders: FolderSettings,
advanced: AdvancedSettings,
};
function groupTabs() {
return GROUP_ORDER.map(group => ({
group,
label: GROUP_LABELS[group],
items: TABS.filter(t => t.group === group),
})).filter(g => g.items.length > 0);
}
interface SettingsScreenProps {
onLogout?: () => void;
onBack?: () => void;
onTabSelect?: (tab: Tab) => void;
}
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
const groupedTabs = groupTabs();
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
setSelectedTab(tab.id);
onTabSelect?.(tab.id);
};
if (selectedTab) {
const tabDef = TABS.find(t => t.id === selectedTab)!;
const Component = TAB_COMPONENTS[selectedTab];
const TabIcon = tabDef.icon;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable
onPress={() => setSelectedTab(null)}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<TabIcon size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>{tabDef.label}</Text>
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
{Component ? <Component /> : null}
</ScrollView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */}
<View style={styles.header}>
<Pressable
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<Settings size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>Settings</Text>
</View>
{/* Tab list — matches webmail mobile: flat grouped list, no cards */}
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
<View style={styles.tabList}>
{groupedTabs.map((group, groupIndex) => (
<View key={group.group}>
{groupIndex > 0 && <View style={styles.groupDivider} />}
<View style={styles.groupHeader}>
<Text style={styles.groupLabel}>{group.label}</Text>
</View>
{group.items.map((tab) => {
const Icon = tab.icon;
const disabled = !tab.implemented;
return (
<Pressable
key={tab.id}
onPress={() => handleTabPress(tab)}
disabled={disabled}
style={({ pressed }) => [
styles.tabItem,
disabled && styles.tabItemDisabled,
!disabled && pressed && styles.tabItemPressed,
]}
>
<View style={styles.tabItemLeft}>
<Icon size={16} color={colors.mutedForeground} />
<Text
style={[
styles.tabItemLabel,
disabled && styles.tabItemLabelDisabled,
]}
>
{tab.label}
</Text>
{tab.experimental && !disabled && (
<View style={styles.experimentalBadge}>
<Text style={styles.experimentalText}>Experimental</Text>
</View>
)}
</View>
{disabled ? (
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not implemented</Text>
</View>
) : (
<ChevronRight size={16} color={colors.mutedForeground} />
)}
</Pressable>
);
})}
</View>
))}
</View>
{/* Logout — matches webmail: border-t, destructive text, icon + label */}
<View style={styles.logoutSection}>
<Pressable
onPress={onLogout}
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
>
<LogOut size={16} color={colors.error} />
<Text style={styles.logoutText}>Sign Out</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
// Header — webmail: h-14, px-4, border-b, flex items-center gap-2
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: spacing.sm,
},
headerBackBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
},
headerBackBtnPressed: {
backgroundColor: colors.accent,
},
headerTitle: {
...typography.h3,
color: colors.text,
},
scrollArea: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
detailContent: {
padding: spacing.lg,
paddingBottom: 40,
},
tabList: {
paddingVertical: spacing.sm,
},
// Group divider — webmail: mx-5 my-2 border-t border-border
groupDivider: {
height: 1,
backgroundColor: colors.border,
marginHorizontal: 20,
marginVertical: spacing.sm,
},
// Group header — webmail: px-5 pt-3 pb-1.5
groupHeader: {
paddingHorizontal: 20,
paddingTop: spacing.md,
paddingBottom: 6,
},
groupLabel: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.8,
color: colors.mutedForeground,
},
// Tab item — webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
tabItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 14,
},
tabItemPressed: {
backgroundColor: colors.muted,
},
tabItemLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
flex: 1,
},
tabItemLabel: {
...typography.body,
color: colors.text,
},
// Experimental badge — webmail: text-[10px] rounded-full bg-warning/15 text-warning
experimentalBadge: {
backgroundColor: 'rgba(202, 138, 4, 0.15)',
borderRadius: radius.full,
paddingHorizontal: 6,
paddingVertical: 2,
},
experimentalText: {
fontSize: 10,
fontWeight: '500',
color: colors.warning,
},
tabItemDisabled: {
opacity: 0.55,
},
tabItemLabelDisabled: {
color: colors.mutedForeground,
},
notWorkingBadge: {
backgroundColor: colors.muted,
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
},
notWorkingText: {
fontSize: 10,
fontWeight: '500',
color: colors.mutedForeground,
},
// Logout — webmail: border-t border-border, px-5 py-3, destructive text
logoutSection: {
borderTopWidth: 1,
borderTopColor: colors.border,
paddingHorizontal: 20,
paddingVertical: spacing.md,
},
logoutBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: 10,
paddingHorizontal: spacing.sm,
borderRadius: radius.sm,
},
logoutBtnPressed: {
backgroundColor: colors.muted,
},
logoutText: {
...typography.body,
color: colors.error,
},
});
+3 -3
View File
@@ -104,7 +104,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (serverUrl, username, password, opts) => {
set({ isLoading: true, error: null });
try {
// Adding an additional account — snapshot/reset feature stores so the
// Adding an additional account - snapshot/reset feature stores so the
// new account starts with a clean slate.
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
@@ -221,7 +221,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const ok = await jmapClient.loadAccount(accountId);
if (!ok) {
// Credentials missing — evict stale entry and surface error
// Credentials missing - evict stale entry and surface error
accountStore.removeAccount(accountId);
set({ isLoading: false, error: 'Session expired for this account' });
return;
@@ -250,7 +250,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try {
// Wait for persisted caches to finish hydrating from AsyncStorage.
// Otherwise we read empty defaults and bounce the user back to the
// login screen — and the feature stores don't have their cached data
// login screen - and the feature stores don't have their cached data
// yet when refetchFeatureStores() checks currentMailboxId / loadedRange
// at the end of this function.
await Promise.all([
+1 -1
View File
@@ -122,7 +122,7 @@ export const useCalendarStore = create<CalendarState>()(
const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before };
const ids = calendars.map((c) => c.id);
if (ids.length === 0) {
// Calendars haven't loaded yet — fetch them, then events.
// Calendars haven't loaded yet - fetch them, then events.
await get().fetchCalendars();
}
const calendarIdsAfter = get().calendars.map((c) => c.id);
+2 -2
View File
@@ -114,7 +114,7 @@ export const useEmailStore = create<EmailState>()(
},
selectMailbox: async (mailboxId) => {
// Reset search/filters when switching mailbox — matches webmail behavior.
// Reset search/filters when switching mailbox - matches webmail behavior.
set({
currentMailboxId: mailboxId,
loading: true,
@@ -157,7 +157,7 @@ export const useEmailStore = create<EmailState>()(
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters } = get();
if (!currentMailboxId) return;
// Keep existing emails visible while refreshing — swap atomically once
// Keep existing emails visible while refreshing - swap atomically once
// the new list is ready so pull-to-refresh doesn't flash an empty list.
set({ loading: true, error: null });
try {
+89
View File
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors } from '../theme/tokens';
const STORAGE_KEY = 'webmail:keywords:v1';
export type KeywordColor = keyof typeof colors.tags;
export interface KeywordDef {
id: string;
label: string;
color: KeywordColor;
}
const DEFAULT_KEYWORDS: KeywordDef[] = [
{ id: 'important', label: 'Important', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'personal', label: 'Personal', color: 'green' },
{ id: 'todo', label: 'Todo', color: 'amber' },
];
/**
* JMAP keyword token used on emails for a given keyword id.
* Matches webmail convention: `$label:<id>`.
*/
export function keywordToken(id: string): string {
return `$label:${id}`;
}
interface KeywordsState {
keywords: KeywordDef[];
hydrated: boolean;
hydrate: () => Promise<void>;
add: (kw: KeywordDef) => void;
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
remove: (id: string) => void;
resetDefaults: () => void;
}
function persist(keywords: KeywordDef[]): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
console.warn('[keywords-store] persist failed', err);
});
}
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
keywords: DEFAULT_KEYWORDS,
hydrated: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as KeywordDef[];
if (Array.isArray(parsed)) {
set({ keywords: parsed, hydrated: true });
return;
}
}
} catch (err) {
console.warn('[keywords-store] hydrate failed', err);
}
set({ hydrated: true });
},
add: (kw) => {
const next = [...get().keywords, kw];
set({ keywords: next });
persist(next);
},
update: (id, patch) => {
const next = get().keywords.map((k) => (k.id === id ? { ...k, ...patch } : k));
set({ keywords: next });
persist(next);
},
remove: (id) => {
const next = get().keywords.filter((k) => k.id !== id);
set({ keywords: next });
persist(next);
},
resetDefaults: () => {
set({ keywords: DEFAULT_KEYWORDS });
persist(DEFAULT_KEYWORDS);
},
}));
+40
View File
@@ -0,0 +1,40 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { detectDeviceLocale, translate, type LocaleCode } from '../i18n';
const STORAGE_KEY = 'webmail:locale:v1';
interface LocaleState {
locale: LocaleCode;
override: LocaleCode | null;
hydrated: boolean;
hydrate: () => Promise<void>;
setOverride: (locale: LocaleCode | null) => void;
t: (key: string, fallback?: string) => string;
}
export const useLocaleStore = create<LocaleState>((set, get) => ({
locale: detectDeviceLocale(),
override: null,
hydrated: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
const override = raw ? (JSON.parse(raw).override as LocaleCode | null) : null;
const locale = override ?? detectDeviceLocale();
set({ override, locale, hydrated: true });
} catch {
set({ hydrated: true });
}
},
setOverride: (override) => {
const locale = override ?? detectDeviceLocale();
set({ override, locale });
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ override })).catch(() => {});
},
t: (key, fallback) => translate(get().locale, key, fallback),
}));
+92 -56
View File
@@ -4,14 +4,33 @@ import type { Identity } from '../api/types';
import { getIdentities as fetchIdentities } from '../api/identity';
export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system';
export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
const STORAGE_KEY = 'webmail:settings:v1';
export type SwipeAction = 'none' | 'archive' | 'delete' | 'spam' | 'read' | 'star';
interface PersistedSettings {
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
senderFavicons: boolean;
groupContactsByLetter: boolean;
// Appearance
theme: ThemeMode;
fontSize: FontSize;
density: Density;
showToolbarLabels: boolean;
animationsEnabled: boolean;
emailAlwaysLightMode: boolean;
// Composing
autoSelectReplyIdentity: boolean;
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
// Layout / list interactions
swipeLeftAction: SwipeAction;
swipeRightAction: SwipeAction;
}
const DEFAULT_PERSISTED: PersistedSettings = {
@@ -19,17 +38,23 @@ const DEFAULT_PERSISTED: PersistedSettings = {
trustedSenders: [],
senderFavicons: true,
groupContactsByLetter: true,
theme: 'system',
fontSize: 'medium',
density: 'regular',
showToolbarLabels: true,
animationsEnabled: true,
emailAlwaysLightMode: false,
autoSelectReplyIdentity: true,
attachmentReminderEnabled: true,
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
swipeLeftAction: 'archive',
swipeRightAction: 'read',
};
export interface SettingsState {
export interface SettingsState extends PersistedSettings {
identities: Identity[];
loading: boolean;
error: string | null;
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
senderFavicons: boolean;
groupContactsByLetter: boolean;
hydrated: boolean;
fetchIdentities: () => Promise<void>;
@@ -37,12 +62,43 @@ export interface SettingsState {
setExternalContentPolicy: (policy: ExternalContentPolicy) => void;
setSenderFavicons: (enabled: boolean) => void;
setGroupContactsByLetter: (enabled: boolean) => void;
setTheme: (theme: ThemeMode) => void;
setFontSize: (size: FontSize) => void;
setDensity: (density: Density) => void;
setShowToolbarLabels: (enabled: boolean) => void;
setAnimationsEnabled: (enabled: boolean) => void;
setEmailAlwaysLightMode: (enabled: boolean) => void;
setAutoSelectReplyIdentity: (enabled: boolean) => void;
setAttachmentReminderEnabled: (enabled: boolean) => void;
setAttachmentReminderKeywords: (keywords: string[]) => void;
setSwipeLeftAction: (action: SwipeAction) => void;
setSwipeRightAction: (action: SwipeAction) => void;
addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void;
isSenderTrusted: (email: string) => boolean;
reset: () => void;
}
function snapshot(state: SettingsState): PersistedSettings {
return {
externalContentPolicy: state.externalContentPolicy,
trustedSenders: state.trustedSenders,
senderFavicons: state.senderFavicons,
groupContactsByLetter: state.groupContactsByLetter,
theme: state.theme,
fontSize: state.fontSize,
density: state.density,
showToolbarLabels: state.showToolbarLabels,
animationsEnabled: state.animationsEnabled,
emailAlwaysLightMode: state.emailAlwaysLightMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
swipeLeftAction: state.swipeLeftAction,
swipeRightAction: state.swipeRightAction,
};
}
function persist(state: PersistedSettings): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)).catch((err) => {
console.warn('[settings-store] persist failed', err);
@@ -50,14 +106,10 @@ function persist(state: PersistedSettings): void {
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
...DEFAULT_PERSISTED,
identities: [],
loading: false,
error: null,
externalContentPolicy: DEFAULT_PERSISTED.externalContentPolicy,
trustedSenders: DEFAULT_PERSISTED.trustedSenders,
senderFavicons: DEFAULT_PERSISTED.senderFavicons,
groupContactsByLetter: DEFAULT_PERSISTED.groupContactsByLetter,
hydrated: false,
fetchIdentities: async () => {
@@ -81,6 +133,17 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
trustedSenders: Array.isArray(parsed.trustedSenders) ? parsed.trustedSenders : [],
senderFavicons: typeof parsed.senderFavicons === 'boolean' ? parsed.senderFavicons : DEFAULT_PERSISTED.senderFavicons,
groupContactsByLetter: typeof parsed.groupContactsByLetter === 'boolean' ? parsed.groupContactsByLetter : DEFAULT_PERSISTED.groupContactsByLetter,
theme: parsed.theme ?? DEFAULT_PERSISTED.theme,
fontSize: parsed.fontSize ?? DEFAULT_PERSISTED.fontSize,
density: parsed.density ?? DEFAULT_PERSISTED.density,
showToolbarLabels: typeof parsed.showToolbarLabels === 'boolean' ? parsed.showToolbarLabels : DEFAULT_PERSISTED.showToolbarLabels,
animationsEnabled: typeof parsed.animationsEnabled === 'boolean' ? parsed.animationsEnabled : DEFAULT_PERSISTED.animationsEnabled,
emailAlwaysLightMode: typeof parsed.emailAlwaysLightMode === 'boolean' ? parsed.emailAlwaysLightMode : DEFAULT_PERSISTED.emailAlwaysLightMode,
autoSelectReplyIdentity: typeof parsed.autoSelectReplyIdentity === 'boolean' ? parsed.autoSelectReplyIdentity : DEFAULT_PERSISTED.autoSelectReplyIdentity,
attachmentReminderEnabled: typeof parsed.attachmentReminderEnabled === 'boolean' ? parsed.attachmentReminderEnabled : DEFAULT_PERSISTED.attachmentReminderEnabled,
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
hydrated: true,
});
return;
@@ -91,61 +154,34 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
set({ hydrated: true });
},
setExternalContentPolicy: (policy) => {
set({ externalContentPolicy: policy });
persist({
externalContentPolicy: policy,
trustedSenders: get().trustedSenders,
senderFavicons: get().senderFavicons,
groupContactsByLetter: get().groupContactsByLetter,
});
},
setSenderFavicons: (enabled) => {
set({ senderFavicons: enabled });
persist({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: get().trustedSenders,
senderFavicons: enabled,
groupContactsByLetter: get().groupContactsByLetter,
});
},
setGroupContactsByLetter: (enabled) => {
set({ groupContactsByLetter: enabled });
persist({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: get().trustedSenders,
senderFavicons: get().senderFavicons,
groupContactsByLetter: enabled,
});
},
setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); },
setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); },
setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); },
setTheme: (theme) => { set({ theme }); persist(snapshot(get())); },
setFontSize: (fontSize) => { set({ fontSize }); persist(snapshot(get())); },
setDensity: (density) => { set({ density }); persist(snapshot(get())); },
setShowToolbarLabels: (enabled) => { set({ showToolbarLabels: enabled }); persist(snapshot(get())); },
setAnimationsEnabled: (enabled) => { set({ animationsEnabled: enabled }); persist(snapshot(get())); },
setEmailAlwaysLightMode: (enabled) => { set({ emailAlwaysLightMode: enabled }); persist(snapshot(get())); },
setAutoSelectReplyIdentity: (enabled) => { set({ autoSelectReplyIdentity: enabled }); persist(snapshot(get())); },
setAttachmentReminderEnabled: (enabled) => { set({ attachmentReminderEnabled: enabled }); persist(snapshot(get())); },
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
addTrustedSender: (email) => {
const normalized = email.toLowerCase().trim();
if (!normalized) return;
const current = get().trustedSenders;
if (current.includes(normalized)) return;
const next = [...current, normalized];
set({ trustedSenders: next });
persist({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: next,
senderFavicons: get().senderFavicons,
groupContactsByLetter: get().groupContactsByLetter,
});
set({ trustedSenders: [...current, normalized] });
persist(snapshot(get()));
},
removeTrustedSender: (email) => {
const normalized = email.toLowerCase().trim();
const next = get().trustedSenders.filter((e) => e !== normalized);
set({ trustedSenders: next });
persist({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: next,
senderFavicons: get().senderFavicons,
groupContactsByLetter: get().groupContactsByLetter,
});
set({ trustedSenders: get().trustedSenders.filter((e) => e !== normalized) });
persist(snapshot(get()));
},
isSenderTrusted: (email) => {
+140
View File
@@ -0,0 +1,140 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Constants from 'expo-constants';
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
import { isNewer } from '../lib/version-compare';
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
interface PersistedUpdates {
autoCheck: boolean;
lastCheckedAt: number;
cachedLatest: LatestRelease | null;
dismissedTag: string | null;
}
const DEFAULT_PERSISTED: PersistedUpdates = {
autoCheck: true,
lastCheckedAt: 0,
cachedLatest: null,
dismissedTag: null,
};
export interface UpdatesState extends PersistedUpdates {
hydrated: boolean;
checking: boolean;
installing: boolean;
installProgress: InstallProgress | null;
error: string | null;
hydrate: () => Promise<void>;
setAutoCheck: (enabled: boolean) => void;
checkNow: (opts?: { force?: boolean }) => Promise<void>;
installLatest: () => Promise<void>;
dismissCurrent: () => void;
currentVersion: () => string;
hasUpdate: () => boolean;
}
function persist(state: PersistedUpdates): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)).catch((err) => {
console.warn('[updates-store] persist failed', err);
});
}
export const useUpdatesStore = create<UpdatesState>((set, get) => ({
...DEFAULT_PERSISTED,
hydrated: false,
checking: false,
installing: false,
installProgress: null,
error: null,
hydrate: async () => {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<PersistedUpdates>;
set({ ...DEFAULT_PERSISTED, ...parsed, hydrated: true });
} else {
set({ hydrated: true });
}
} catch (err) {
console.warn('[updates-store] hydrate failed', err);
set({ hydrated: true });
}
},
setAutoCheck: (enabled) => {
set({ autoCheck: enabled });
const s = get();
persist({
autoCheck: enabled,
lastCheckedAt: s.lastCheckedAt,
cachedLatest: s.cachedLatest,
dismissedTag: s.dismissedTag,
});
},
checkNow: async (opts) => {
const s = get();
if (s.checking) return;
const now = Date.now();
if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return;
set({ checking: true, error: null });
try {
const latest = await fetchLatestRelease();
const next: PersistedUpdates = {
autoCheck: s.autoCheck,
lastCheckedAt: now,
cachedLatest: latest,
dismissedTag: s.dismissedTag,
};
set({ ...next, checking: false });
persist(next);
} catch (err) {
set({ checking: false, error: err instanceof Error ? err.message : String(err) });
}
},
installLatest: async () => {
const s = get();
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
try {
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
set({ installProgress: p });
});
set({ installing: false, installProgress: null });
} catch (err) {
set({
installing: false,
installProgress: null,
error: err instanceof Error ? err.message : String(err),
});
}
},
dismissCurrent: () => {
const s = get();
const tag = s.cachedLatest?.tag ?? null;
set({ dismissedTag: tag });
persist({
autoCheck: s.autoCheck,
lastCheckedAt: s.lastCheckedAt,
cachedLatest: s.cachedLatest,
dismissedTag: tag,
});
},
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
hasUpdate: () => {
const s = get();
if (!s.cachedLatest) return false;
const current = Constants.expoConfig?.version ?? '0.0.0';
return isNewer(s.cachedLatest.tag, current);
},
}));
+6 -6
View File
@@ -1,4 +1,4 @@
// Qui Dark Theme — exact values from Bulwark Webmail's builtin-themes.ts
// Qui Dark Theme - exact values from Bulwark Webmail's builtin-themes.ts
export const colors = {
// Primary brand (Qui theme)
primary: '#3b82f6',
@@ -69,7 +69,7 @@ export const colors = {
orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark
},
// Tag colors — from KEYWORD_PALETTE in stores/settings-store.ts
// Tag colors - from KEYWORD_PALETTE in stores/settings-store.ts
// dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400
tags: {
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
@@ -87,7 +87,7 @@ export const colors = {
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
},
// Navigation — matches web nav-rail
// Navigation - matches web nav-rail
navActive: '#3b82f6',
navInactive: '#71717a', // --color-muted-foreground
navBadge: '#ef4444', // red-500
@@ -145,18 +145,18 @@ export const typography = {
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg
// Body = text-sm (14px) — the web uses text-sm for almost all content
// Body = text-sm (14px) - the web uses text-sm for almost all content
body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 },
bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 },
bodyBold: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 },
// Base = text-base (16px) — used for email content body
// Base = text-base (16px) - used for email content body
base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 },
// Caption = text-xs (12px)
caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 },
captionMedium: { fontSize: 12, fontWeight: '500' as const, lineHeight: 16 },
// Small = 10px — for tags, badges, tab labels
// Small = 10px - for tags, badges, tab labels
small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
} as const;
+2 -1
View File
@@ -1,6 +1,7 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
"strict": true,
"resolveJsonModule": true
}
}