mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792b028b7a | ||
|
|
ac84f13b22 | ||
|
|
571b28a9f2 | ||
|
|
a95783723a | ||
|
|
b36e065ebe | ||
|
|
0d7bf3204a | ||
|
|
a76e0fe8c0 | ||
|
|
cee4193e58 | ||
|
|
5ac2a142b5 | ||
|
|
e73dc4e5ba | ||
|
|
892ff33f14 | ||
|
|
7e42cb85ea |
@@ -42,6 +42,16 @@ jobs:
|
||||
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||
restore-keys: android-sdk-${{ runner.os }}-
|
||||
|
||||
- name: Compute native cache key input
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
|
||||
if (lock.packages && lock.packages['']) delete lock.packages[''].version;
|
||||
delete lock.version;
|
||||
fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {}));
|
||||
"
|
||||
|
||||
- name: Cache native CMake build outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -50,7 +60,7 @@ jobs:
|
||||
android/app/build/intermediates/cxx
|
||||
android/app/build/intermediates/merged_native_libs
|
||||
android/app/build/intermediates/stripped_native_libs
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
restore-keys: cxx-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -34,6 +35,8 @@ import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { colors, spacing, typography } from './src/theme/tokens';
|
||||
@@ -174,6 +177,8 @@ export default function App() {
|
||||
|
||||
React.useEffect(() => {
|
||||
void useSettingsStore.getState().hydrate();
|
||||
void useLocaleStore.getState().hydrate();
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -325,6 +330,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}
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
# React Native Calendar - Parity Plan
|
||||
|
||||
Bring `repos/react-native/` calendar to parity with the webmail calendar at
|
||||
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus
|
||||
utility libs; mobile port targets ~2,500 lines with feature parity on the
|
||||
essentials and explicit deferrals for things that don't fit a phone UX.
|
||||
|
||||
## Current state
|
||||
|
||||
- `src/screens/CalendarScreenNew.tsx` - month grid + agenda-for-selected-day,
|
||||
all rendering `MOCK_EVENTS`. Doesn't touch the store.
|
||||
- `src/stores/calendar-store.ts` - CRUD wiring exists (`fetchCalendars`,
|
||||
`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
|
||||
`handleStateChange`, no per-calendar visibility, no range tracking.
|
||||
- `src/api/calendar.ts` - JMAP methods wired (`Calendar/get`,
|
||||
`CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`).
|
||||
- `src/api/types.ts` - `CalendarEvent` and `Calendar` types match webmail.
|
||||
- No event modal, no detail view, no week/day view, no sidebar, no recurrence.
|
||||
|
||||
## In scope
|
||||
|
||||
### Phase 1 - Data layer (foundation for everything else)
|
||||
|
||||
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
|
||||
|
||||
- `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
|
||||
`CalendarSidebar` toggle state).
|
||||
- `loadedRange: { after: string; before: string } | null` - track what's fetched
|
||||
so view changes don't re-fetch unnecessarily.
|
||||
- `ensureRange(after, before)` - fetch only if the requested window isn't
|
||||
covered by `loadedRange`; expand the loaded range in place instead of
|
||||
replacing events wholesale, so switching Month → Week doesn't drop data.
|
||||
- `handleStateChange(change)` - subscribe to `Calendar` and `CalendarEvent`
|
||||
typeState keys; on push, refetch calendars and re-query the current window.
|
||||
Already referenced from `App.tsx` push handler - this finally implements it.
|
||||
- `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
|
||||
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
|
||||
|
||||
**`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`:
|
||||
|
||||
- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time
|
||||
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
|
||||
- `parseDuration(iso8601)` - `PT1H30M` → milliseconds.
|
||||
- `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }`
|
||||
handling `showWithoutTime`.
|
||||
- `eventsOnDay(events, day)` - day overlap check (not just start-matches-day).
|
||||
- `getCalendarColor(cal)` - fallback to a deterministic palette when `color`
|
||||
is missing, matching `theme/tokens.ts` `colors.calendar.*`.
|
||||
|
||||
**`src/lib/recurrence-expansion.ts`** (new) - minimal port of webmail's RRULE
|
||||
expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`.
|
||||
Mobile port: same surface, same tests. This is the hard part - allocate ~400
|
||||
lines. Drives correct rendering of recurring events across the visible window.
|
||||
|
||||
### Phase 2 - Views
|
||||
|
||||
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
|
||||
|
||||
- On mount: `fetchCalendars()`, then `ensureRange` for visible window.
|
||||
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone).
|
||||
- Header shows month/week label; prev/next shifts by the current view's step.
|
||||
- "Today" button resets both `currentDate` and `selectedDate`.
|
||||
- FAB opens create modal with `start = selectedDate at next half hour`.
|
||||
|
||||
**`src/components/calendar/MonthView.tsx`** (extract from current screen,
|
||||
~180 lines) - real events, up to 3 colored dots per day, "+N" indicator when
|
||||
more. Tap day → selects + scrolls agenda below. Long-press day → opens create
|
||||
modal with that date.
|
||||
|
||||
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) - port of
|
||||
`calendar-week-view.tsx` (511 lines) trimmed for mobile:
|
||||
|
||||
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
|
||||
- All-day strip at top.
|
||||
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among
|
||||
overlapping events (port webmail's stacking algorithm).
|
||||
- Tap event → detail modal. Long-press empty slot → create modal pre-filled.
|
||||
- Horizontal swipe → prev/next week (react-native-gesture-handler already
|
||||
available via react-navigation).
|
||||
- _Deferred_: drag-to-reschedule, drag-to-resize. Phone targets are too small
|
||||
for it to be better than tap-edit.
|
||||
|
||||
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) - port of
|
||||
`calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
|
||||
days forward. Taps open detail modal.
|
||||
|
||||
### Phase 3 - Event interaction
|
||||
|
||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) - bottom
|
||||
sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines
|
||||
on web - phone version drops the hover card chrome and the inline-edit fields):
|
||||
|
||||
- Header: calendar color bar, title, date/time range (or "All day"), calendar name.
|
||||
- Sections: location, description (linkified), participants with RSVP status,
|
||||
recurrence summary (e.g. "Weekly on Mon, Wed - until 2026-06-01"), reminders.
|
||||
- Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
|
||||
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
|
||||
only" / "This and following" / "All events").
|
||||
|
||||
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) - full-screen
|
||||
modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
|
||||
|
||||
- Title, calendar picker (color swatches), all-day toggle.
|
||||
- Start/end date+time pickers (`@react-native-community/datetimepicker` - add dep).
|
||||
- Location, description (multiline).
|
||||
- Participant input with autocomplete from contacts store (port of
|
||||
`participant-input.tsx`, ~180 lines).
|
||||
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
|
||||
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.)
|
||||
are deferred - covers the 95% case.
|
||||
- Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
|
||||
`Alert.trigger.offset`.
|
||||
- Timezone: default to device TZ; optional override picker hidden behind
|
||||
"Advanced" disclosure.
|
||||
- Save path handles "this instance only" override vs master update for
|
||||
recurring events (write to `recurrenceOverrides[recurrenceId]`).
|
||||
|
||||
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) -
|
||||
direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
|
||||
edit/delete of a recurring instance.
|
||||
|
||||
### Phase 4 - Multi-calendar
|
||||
|
||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) -
|
||||
port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
|
||||
(reuses `SidebarDrawer.tsx`):
|
||||
|
||||
- List of calendars with color swatch + visibility toggle + name.
|
||||
- "My calendars" vs "Subscribed" sections if `myRights` differs.
|
||||
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
|
||||
- Footer: "Add calendar" (deferred to Phase 6).
|
||||
|
||||
**`src/theme/tokens.ts`** - audit `colors.calendar.*` palette against webmail's
|
||||
calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
|
||||
add any missing shades.
|
||||
|
||||
### Phase 5 - Polish
|
||||
|
||||
- Pull-to-refresh on each view → forces re-fetch of current window.
|
||||
- Empty states per view (no calendars yet / no events this month).
|
||||
- Loading skeleton for month grid and week grid.
|
||||
- Error banner when fetch fails (pattern from `EmailListScreen`).
|
||||
- Respect `email-store` push via `handleStateChange` so events update live
|
||||
when another client creates one.
|
||||
|
||||
## Explicitly deferred (out of scope)
|
||||
|
||||
These are in the webmail but don't make sense for a first mobile pass:
|
||||
|
||||
- **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines,
|
||||
`ical-subscription-modal.tsx` 220 lines) - file picker + URL subscription
|
||||
with auth flows. Better as a dedicated settings flow later.
|
||||
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) -
|
||||
JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
|
||||
screen, not bolted onto calendar.
|
||||
- **Drag to reschedule / resize** - mobile tap targets are too small for the
|
||||
fine-grained control the web drag gives; tap-edit is the mobile idiom.
|
||||
- **Quick event input** (`quick-event-input.tsx`) - NLP parser ("Lunch tomorrow
|
||||
at noon"). Neat on desktop, marginal on phone where typing is already slow.
|
||||
- **Event context menu** (`event-context-menu.tsx`) - right-click is desktop-
|
||||
only. Actions move into the detail sheet's action row.
|
||||
- **Mini-calendar** (`mini-calendar.tsx`) - web uses it as a sidebar date
|
||||
picker. Mobile already has the main month view; redundant.
|
||||
- **Birthday calendar auto-derived events** - lower priority; leave the
|
||||
contacts-store integration for a follow-up.
|
||||
|
||||
## Dependencies to add
|
||||
|
||||
- `@react-native-community/datetimepicker` - native date/time pickers.
|
||||
- `react-native-reanimated` is already in (via react-navigation); reuse for
|
||||
bottom-sheet animations on `EventDetailSheet`.
|
||||
- No new calendar library - reimplement RRULE expansion in ~400 lines rather
|
||||
than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
|
||||
|
||||
## File map (new / modified)
|
||||
|
||||
```
|
||||
repos/react-native/src/
|
||||
├── screens/
|
||||
│ └── CalendarScreenNew.tsx [rewrite]
|
||||
├── components/
|
||||
│ └── calendar/ [new directory]
|
||||
│ ├── MonthView.tsx [new]
|
||||
│ ├── WeekView.tsx [new]
|
||||
│ ├── AgendaView.tsx [new]
|
||||
│ ├── EventCard.tsx [new]
|
||||
│ ├── EventDetailSheet.tsx [new]
|
||||
│ ├── EventModal.tsx [new]
|
||||
│ ├── ParticipantInput.tsx [new]
|
||||
│ ├── RecurrenceScopeDialog.tsx [new]
|
||||
│ ├── CalendarSidebarDrawer.tsx [new]
|
||||
│ └── index.ts [new]
|
||||
├── stores/
|
||||
│ └── calendar-store.ts [extend]
|
||||
├── lib/
|
||||
│ ├── calendar-utils.ts [new]
|
||||
│ └── recurrence-expansion.ts [new]
|
||||
└── theme/
|
||||
└── tokens.ts [audit calendar palette]
|
||||
```
|
||||
|
||||
## Testing approach
|
||||
|
||||
- `vitest.config.ts` already exists. Unit tests for:
|
||||
- `recurrence-expansion` (daily/weekly/monthly, count+until, byDay).
|
||||
- `calendar-utils` (duration parse, all-day overlap, DST boundary).
|
||||
- Store: `ensureRange` dedup, `handleStateChange` refetch trigger.
|
||||
- Manual emulator test matrix:
|
||||
- Create event (timed, all-day, recurring), verify server round-trip.
|
||||
- Edit recurring event via scope dialog.
|
||||
- Delete recurring instance vs series.
|
||||
- Toggle calendar visibility, confirm events disappear.
|
||||
- Push update from webmail, confirm mobile refreshes.
|
||||
|
||||
## Build order (suggested)
|
||||
|
||||
1. Phase 1 in full - nothing else works without real data.
|
||||
2. MonthView + EventDetailSheet (read path) - first thing user sees.
|
||||
3. EventModal create path - unblocks manual testing of write path.
|
||||
4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together.
|
||||
5. WeekView - biggest UI, easiest to defer if time runs short.
|
||||
6. EventModal edit + recurrence scope - finishes the edit surface.
|
||||
7. Phase 5 polish.
|
||||
|
||||
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util
|
||||
changes. Each phase is ship-safe on its own - the app stays functional even if
|
||||
we stop at Phase 2.
|
||||
@@ -1,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"/>
|
||||
|
||||
@@ -28,7 +28,7 @@ android.enablePngCrunchInReleaseBuilds=true
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
reactNativeArchitectures=arm64-v8a
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
|
||||
+1
-1
@@ -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
Generated
+32
-2
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.9",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
@@ -19,6 +20,7 @@
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
@@ -2792,6 +2794,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/netinfo": {
|
||||
"version": "11.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
|
||||
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.59"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||
@@ -5245,6 +5256,19 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
|
||||
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rtl-detect": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||
@@ -8789,6 +8813,12 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-detect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.9",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -9,11 +9,14 @@
|
||||
"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",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
@@ -22,6 +25,7 @@
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { CloudOff } from 'lucide-react-native';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { colors, spacing, typography } from '../theme/tokens';
|
||||
|
||||
interface OfflineBannerProps {
|
||||
/** Optional extra notice (e.g. "Showing cached mail"). */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function OfflineBanner({ hint }: OfflineBannerProps) {
|
||||
const online = useNetworkStore((s) => s.online);
|
||||
if (online) return null;
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<CloudOff size={14} color={colors.primaryForeground} />
|
||||
<Text style={styles.text} numberOfLines={1}>
|
||||
You are offline{hint ? ` — ${hint}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: '#a16207',
|
||||
},
|
||||
text: {
|
||||
...typography.caption,
|
||||
color: colors.primaryForeground,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useRef, useState } 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, sits at right edge). */
|
||||
leftAction: SwipeAction;
|
||||
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
|
||||
rightAction: SwipeAction;
|
||||
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
|
||||
context: { unread: boolean; starred: boolean };
|
||||
onAction: (action: SwipeAction) => void;
|
||||
}
|
||||
|
||||
const REVEAL_WIDTH = 88; // px shown when fully revealed
|
||||
const ACTIVATION_THRESHOLD = 32; // px past which release snaps open instead of closing
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
const MAX_DRAG_OVERSHOOT = REVEAL_WIDTH * 1.4;
|
||||
|
||||
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 openSideRef = useRef<'left' | 'right' | null>(null);
|
||||
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
|
||||
|
||||
const close = () => {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = null;
|
||||
setOpenSide(null);
|
||||
};
|
||||
|
||||
const openRight = () => {
|
||||
Animated.spring(dx, { toValue: REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = 'right';
|
||||
setOpenSide('right');
|
||||
};
|
||||
|
||||
const openLeft = () => {
|
||||
Animated.spring(dx, { toValue: -REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = 'left';
|
||||
setOpenSide('left');
|
||||
};
|
||||
|
||||
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 a side is already open, allow the gesture so the user can drag it closed.
|
||||
if (openSideRef.current === null) {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
}
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
|
||||
const next = base + g.dx;
|
||||
const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, next));
|
||||
dx.setValue(clamped);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
|
||||
const finalDx = base + g.dx;
|
||||
|
||||
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||
openRight();
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||
openLeft();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
close();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
).current;
|
||||
|
||||
const fireAction = (action: SwipeAction) => {
|
||||
close();
|
||||
if (action !== 'none') onAction(action);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={() => fireAction(action)}
|
||||
style={[
|
||||
styles.band,
|
||||
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
|
||||
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
|
||||
]}
|
||||
>
|
||||
<View style={styles.bandInner}>
|
||||
<Icon size={20} color="#fff" />
|
||||
<Text style={styles.bandLabel}>{label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
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={[styles.content, { transform: [{ translateX: dx }] }]}>
|
||||
{children}
|
||||
{/* When an action is revealed, an overlay absorbs taps on the row so a
|
||||
tap closes the swipe instead of opening the email. */}
|
||||
{openSide ? (
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={close}
|
||||
/>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { position: 'relative', backgroundColor: colors.background },
|
||||
content: { backgroundColor: colors.background },
|
||||
band: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
bandInner: { alignItems: 'center', gap: 4 },
|
||||
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -282,57 +282,6 @@ export function ReadingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Always Light Mode for Emails" description="Render email content on a white background.">
|
||||
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="External Content" description="How to handle images and remote resources in incoming email.">
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
onChange={(v) => setExternalContentPolicyStore(v as ExternalContentPolicy)}
|
||||
options={[
|
||||
{ value: 'ask', label: 'Ask' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.group}>
|
||||
<SettingItem
|
||||
label="Trusted Senders"
|
||||
description="Senders whose external content loads without asking."
|
||||
noBorder
|
||||
>
|
||||
<Pressable style={styles.inlineBtn} onPress={() => setShowTrustedList((v) => !v)}>
|
||||
<Text style={styles.inlineBtnText}>
|
||||
{trustedSenders.length === 0
|
||||
? 'No trusted senders'
|
||||
: `${trustedSenders.length} sender${trustedSenders.length === 1 ? '' : 's'}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</SettingItem>
|
||||
{showTrustedList && trustedSenders.length > 0 && (
|
||||
<View style={styles.trustedList}>
|
||||
{trustedSenders.map((email) => (
|
||||
<View key={email} style={styles.trustedRow}>
|
||||
<Text style={styles.trustedEmail} numberOfLines={1}>{email}</Text>
|
||||
<Pressable onPress={() => removeTrustedSender(email)} hitSlop={8}>
|
||||
<X size={14} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
|
||||
<SettingItem label="Sync Trusted Senders" description="Store trusted senders in the address book.">
|
||||
<ToggleSwitch
|
||||
checked={trustedSendersAddressBook}
|
||||
onChange={setTrustedSendersAddressBook}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -11,11 +12,37 @@ function formatTime(ts: number): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatProgressLabel(p: InstallProgress): string {
|
||||
const pct = p.progress != null ? `${Math.round(p.progress * 100)}%` : null;
|
||||
const written = p.bytesWritten ? formatBytes(p.bytesWritten) : null;
|
||||
const total = p.totalBytes ? formatBytes(p.totalBytes) : null;
|
||||
const sizes = written && total ? `${written} of ${total}` : written ?? '';
|
||||
return [pct, sizes].filter(Boolean).join(' · ') || 'Downloading…';
|
||||
}
|
||||
|
||||
function installButtonLabel(
|
||||
installing: boolean,
|
||||
progress: InstallProgress | null,
|
||||
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);
|
||||
@@ -81,7 +108,7 @@ export function UpdatesSettings() {
|
||||
onPress={onInstall}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
|
||||
{installButtonLabel(installing, installProgress, apkAsset)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -95,6 +122,29 @@ export function UpdatesSettings() {
|
||||
)}
|
||||
</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>
|
||||
@@ -121,4 +171,20 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -117,9 +117,29 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
|
||||
safety++;
|
||||
}
|
||||
}
|
||||
// Sort each day's bucket: all-day events first, then timed events by start.
|
||||
// Without this, events from different calendars come out in arrival order
|
||||
// (roughly grouped by calendar) rather than chronologically.
|
||||
for (const arr of idx.values()) {
|
||||
arr.sort(compareEventsForDay);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function compareEventsForDay(a: CalendarEvent, b: CalendarEvent): number {
|
||||
const aAllDay = !!a.showWithoutTime;
|
||||
const bAllDay = !!b.showWithoutTime;
|
||||
if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
|
||||
const aStart = getEventStartDate(a).getTime();
|
||||
const bStart = getEventStartDate(b).getTime();
|
||||
if (aStart !== bStart) return aStart - bStart;
|
||||
// Tie-breaker on end (shorter events first), then title for stability.
|
||||
const aEnd = getEventEndDate(a).getTime();
|
||||
const bEnd = getEventEndDate(b).getTime();
|
||||
if (aEnd !== bEnd) return aEnd - bEnd;
|
||||
return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
}
|
||||
|
||||
export function eventsOnDayFromIndex(
|
||||
index: EventDayIndex,
|
||||
day: Date,
|
||||
|
||||
@@ -0,0 +1,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',
|
||||
});
|
||||
}
|
||||
@@ -7,16 +7,55 @@ import type { ReleaseAsset } from '../api/updates';
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
|
||||
|
||||
export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void> {
|
||||
export interface InstallProgress {
|
||||
phase: 'downloading' | 'installing';
|
||||
/** 0-1 during download; undefined for installing phase */
|
||||
progress?: number;
|
||||
/** Bytes received during current download */
|
||||
bytesWritten?: number;
|
||||
/** Total bytes for the download (best-effort, may be 0 if server omits Content-Length) */
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
export type InstallProgressListener = (p: InstallProgress) => void;
|
||||
|
||||
export async function downloadAndInstallApk(
|
||||
asset: ReleaseAsset,
|
||||
onProgress?: InstallProgressListener,
|
||||
): Promise<void> {
|
||||
if (Platform.OS !== 'android') {
|
||||
throw new Error('APK install is only supported on Android');
|
||||
}
|
||||
|
||||
const dest = new File(Paths.cache, asset.name);
|
||||
if (dest.exists) dest.delete();
|
||||
const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest);
|
||||
|
||||
const contentUri = await FileSystemLegacy.getContentUriAsync(downloaded.uri);
|
||||
onProgress?.({ phase: 'downloading', progress: 0 });
|
||||
|
||||
const download = FileSystemLegacy.createDownloadResumable(
|
||||
asset.browser_download_url,
|
||||
dest.uri,
|
||||
{},
|
||||
(state) => {
|
||||
const total = state.totalBytesExpectedToWrite ?? 0;
|
||||
const progress = total > 0 ? state.totalBytesWritten / total : undefined;
|
||||
onProgress?.({
|
||||
phase: 'downloading',
|
||||
progress,
|
||||
bytesWritten: state.totalBytesWritten,
|
||||
totalBytes: total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const result = await download.downloadAsync();
|
||||
if (!result?.uri) {
|
||||
throw new Error('APK download failed');
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'installing' });
|
||||
|
||||
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
|
||||
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: contentUri,
|
||||
@@ -24,3 +63,16 @@ export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void>
|
||||
flags: FLAG_GRANT_READ_URI_PERMISSION | FLAG_ACTIVITY_NEW_TASK,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the system "Install unknown apps" settings page for this app, so the
|
||||
* user can grant permission to install APKs from the package installer.
|
||||
* Useful as a fallback if the install intent never reaches the installer.
|
||||
*/
|
||||
export async function openInstallUnknownAppsSettings(): Promise<void> {
|
||||
if (Platform.OS !== 'android') return;
|
||||
await IntentLauncher.startActivityAsync(
|
||||
'android.settings.MANAGE_UNKNOWN_APP_SOURCES',
|
||||
{ data: 'package:com.anonymous.bulwarkmobile' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -9,7 +9,11 @@ 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 { OfflineBanner } from '../components/OfflineBanner';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
function getSenderName(email: Email): string {
|
||||
@@ -152,6 +156,24 @@ 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 networkOnline = useNetworkStore((s) => s.online);
|
||||
|
||||
const archiveMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const trashMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const junkMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
|
||||
// Selection state
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
@@ -187,17 +209,57 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}
|
||||
}, [toggleSelect]);
|
||||
|
||||
const handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => {
|
||||
if (action === 'none') return;
|
||||
const email = emailsRef.current.find((e) => e.id === id);
|
||||
if (!email || !currentMailboxId) return;
|
||||
switch (action) {
|
||||
case 'archive':
|
||||
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||
void 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 }) => (
|
||||
<EmailRow
|
||||
item={item}
|
||||
selected={selectedIds.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onPress={handleRowPress}
|
||||
onLongPress={toggleSelect}
|
||||
/>
|
||||
<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],
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
|
||||
);
|
||||
|
||||
const clearSelection = React.useCallback(() => {
|
||||
@@ -493,15 +555,19 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
</View>
|
||||
)}
|
||||
|
||||
<OfflineBanner hint={emails.length > 0 ? 'Showing cached mail' : undefined} />
|
||||
|
||||
{/* Email list */}
|
||||
{loading && emails.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading emails...</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
) : error && emails.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{networkOnline ? error : 'No connection. Showing nothing because no mail has been cached yet.'}
|
||||
</Text>
|
||||
<Pressable onPress={() => { void refreshEmails(); }}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
||||
Modal, useWindowDimensions, Animated, Easing,
|
||||
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';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||
Inbox, Send, File as FileIcon, Ban, Folder,
|
||||
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';
|
||||
@@ -17,6 +17,8 @@ import EmailBodyView from '../components/EmailBodyView';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
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';
|
||||
@@ -85,6 +87,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
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;
|
||||
@@ -130,6 +137,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
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;
|
||||
const next = { ...email.keywords };
|
||||
@@ -386,15 +402,38 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
visible={moreMenuOpen}
|
||||
onClose={() => setMoreMenuOpen(false)}
|
||||
unread={unread}
|
||||
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
|
||||
canMarkUnread={!showMarkUnread}
|
||||
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
|
||||
@@ -404,6 +443,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={onMoveToMailbox}
|
||||
/>
|
||||
|
||||
<TagMenuSheet
|
||||
visible={tagMenuOpen}
|
||||
onClose={() => setTagMenuOpen(false)}
|
||||
keywords={keywordDefs}
|
||||
activeKeywords={email?.keywords ?? {}}
|
||||
onToggle={onToggleKeyword}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -415,17 +462,24 @@ interface MoreMenuSheetProps {
|
||||
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,
|
||||
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
|
||||
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;
|
||||
@@ -491,6 +545,14 @@ function MoreMenuSheet({
|
||||
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={
|
||||
@@ -504,6 +566,90 @@ function MoreMenuSheet({
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,22 @@ 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';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
@@ -53,50 +69,66 @@ const GROUP_ORDER: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'ap
|
||||
const TABS: TabDef[] = [
|
||||
// General
|
||||
{ id: 'account', label: 'Account', icon: User, group: 'general', implemented: true },
|
||||
{ id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: false },
|
||||
{ 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: false },
|
||||
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: false },
|
||||
{ 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: false },
|
||||
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true },
|
||||
|
||||
// Privacy & Security
|
||||
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: false },
|
||||
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: false },
|
||||
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: false },
|
||||
{ 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: 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: '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: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: false },
|
||||
{ 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
|
||||
|
||||
interface NetworkState {
|
||||
/** Best-effort online flag. True when we have an internet-reachable connection
|
||||
* (or when NetInfo hasn't yet reported anything — assume online by default
|
||||
* so we don't flash the offline banner during cold start). */
|
||||
online: boolean;
|
||||
/** Lower-level connection flag. True if a network interface is up, regardless
|
||||
* of whether the internet is actually reachable. */
|
||||
connected: boolean;
|
||||
|
||||
init: () => () => void;
|
||||
}
|
||||
|
||||
function fromNetInfo(state: NetInfoState): { online: boolean; connected: boolean } {
|
||||
const connected = state.isConnected === true;
|
||||
// isInternetReachable is `null` until tested; treat null as "we don't know"
|
||||
// and fall back to connected.
|
||||
const online = state.isInternetReachable === false ? false : connected;
|
||||
return { online, connected };
|
||||
}
|
||||
|
||||
export const useNetworkStore = create<NetworkState>((set) => ({
|
||||
online: true,
|
||||
connected: true,
|
||||
|
||||
init: () => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
set(fromNetInfo(state));
|
||||
});
|
||||
void NetInfo.fetch().then((state) => set(fromNetInfo(state)));
|
||||
return unsubscribe;
|
||||
},
|
||||
}));
|
||||
@@ -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) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 } from '../lib/install-update';
|
||||
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
|
||||
|
||||
const STORAGE_KEY = 'webmail:updates:v1';
|
||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
@@ -26,6 +26,7 @@ export interface UpdatesState extends PersistedUpdates {
|
||||
hydrated: boolean;
|
||||
checking: boolean;
|
||||
installing: boolean;
|
||||
installProgress: InstallProgress | null;
|
||||
error: string | null;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
@@ -48,6 +49,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
hydrated: false,
|
||||
checking: false,
|
||||
installing: false,
|
||||
installProgress: null,
|
||||
error: null,
|
||||
|
||||
hydrate: async () => {
|
||||
@@ -100,12 +102,18 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
installLatest: async () => {
|
||||
const s = get();
|
||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||
set({ installing: true, error: null });
|
||||
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
||||
try {
|
||||
await downloadAndInstallApk(s.cachedLatest.apkAsset);
|
||||
set({ installing: false });
|
||||
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
|
||||
set({ installProgress: p });
|
||||
});
|
||||
set({ installing: false, installProgress: null });
|
||||
} catch (err) {
|
||||
set({ installing: false, error: err instanceof Error ? err.message : String(err) });
|
||||
set({
|
||||
installing: false,
|
||||
installProgress: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user