mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbfb887e0a | ||
|
|
10db4c503a | ||
|
|
abbda7a74d | ||
|
|
fe7d9bdf09 | ||
|
|
a8a0d475db | ||
|
|
dd2f941dbb | ||
|
|
2fa48e8e05 | ||
|
|
b274d4b76b | ||
|
|
d5710128fd | ||
|
|
96bddad289 |
@@ -28,9 +28,31 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Android NDK / CMake
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
/usr/local/lib/android/sdk/ndk
|
||||
/usr/local/lib/android/sdk/cmake
|
||||
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||
restore-keys: android-sdk-${{ runner.os }}-
|
||||
|
||||
- name: Cache native CMake build outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
android/app/.cxx
|
||||
android/app/build/intermediates/cxx
|
||||
android/app/build/intermediates/merged_native_libs
|
||||
android/app/build/intermediates/stripped_native_libs
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
restore-keys: cxx-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -54,7 +76,9 @@ jobs:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: ./gradlew assembleRelease --no-daemon
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: Rename APK with version
|
||||
id: artifact
|
||||
@@ -72,9 +96,28 @@ jobs:
|
||||
path: ${{ steps.artifact.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Resolve target release tag
|
||||
id: target_tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||
else
|
||||
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$TAG" ]; then
|
||||
echo "Will attach APK to release: $TAG"
|
||||
else
|
||||
echo "No release tag resolved - APK is in workflow artifacts only."
|
||||
fi
|
||||
|
||||
- name: Attach APK to release
|
||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.attach_to_release_tag != '')
|
||||
if: steps.target_tag.outputs.tag != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name || github.event.inputs.attach_to_release_tag }}
|
||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||
files: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
@@ -34,6 +34,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 { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { colors, spacing, typography } from './src/theme/tokens';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
@@ -64,6 +66,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<UpdateBanner />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -147,6 +151,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
{() => <SettingsScreen onLogout={logout} />}
|
||||
</Tab.Screen>
|
||||
</Tab.Navigator>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,8 +176,21 @@ export default function App() {
|
||||
void useSettingsStore.getState().hydrate();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const store = useUpdatesStore.getState();
|
||||
await store.hydrate();
|
||||
if (cancelled) return;
|
||||
if (useUpdatesStore.getState().autoCheck) {
|
||||
await useUpdatesStore.getState().checkNow();
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background — the FirebaseMessagingService also posts the system
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = addMessageListener((payload) => {
|
||||
|
||||
+60
-53
@@ -1,4 +1,4 @@
|
||||
# React Native Calendar — Parity Plan
|
||||
# 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
|
||||
@@ -7,52 +7,55 @@ 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,
|
||||
- `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`,
|
||||
- `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`,
|
||||
- `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.
|
||||
- `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)
|
||||
### Phase 1 - Data layer (foundation for everything else)
|
||||
|
||||
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
|
||||
|
||||
**`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
|
||||
- `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
|
||||
- `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`
|
||||
- `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.
|
||||
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
|
||||
**`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 }`
|
||||
- `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`
|
||||
- `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
|
||||
**`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
|
||||
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
|
||||
### Phase 2 - Views
|
||||
|
||||
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
|
||||
|
||||
**`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.
|
||||
@@ -60,12 +63,13 @@ lines. Drives correct rendering of recurring events across the visible window.
|
||||
- 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
|
||||
~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
|
||||
**`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
|
||||
@@ -73,35 +77,37 @@ modal with that date.
|
||||
- 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
|
||||
- _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
|
||||
**`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
|
||||
### Phase 3 - Event interaction
|
||||
|
||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) — bottom
|
||||
**`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):
|
||||
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.
|
||||
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
|
||||
**`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).
|
||||
- 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.
|
||||
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
|
||||
@@ -109,25 +115,26 @@ modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed)
|
||||
- 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) —
|
||||
**`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
|
||||
### Phase 4 - Multi-calendar
|
||||
|
||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) —
|
||||
**`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
|
||||
**`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
|
||||
### 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).
|
||||
@@ -141,28 +148,28 @@ add any missing shades.
|
||||
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
|
||||
`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`) —
|
||||
- **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
|
||||
- **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
|
||||
- **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-
|
||||
- **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
|
||||
- **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
|
||||
- **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-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
|
||||
- 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)
|
||||
@@ -207,14 +214,14 @@ repos/react-native/src/
|
||||
|
||||
## 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.
|
||||
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
|
||||
changes. Each phase is ship-safe on its own - the app stays functional even if
|
||||
we stop at Phase 2.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
|
||||
# Bulwark Mobile
|
||||
|
||||
> **Beta - work in progress.** Many features are unfinished or rough. Expect bugs, missing functionality, and breaking changes between releases. Do not rely on this for primary email yet.
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
- Multiple accounts
|
||||
- Email list, threads, compose
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via FCM relay
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS build is untested
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases)
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
@@ -166,7 +166,7 @@ dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
// Firebase Cloud Messaging — delivers pushes via Google Play services.
|
||||
// Firebase Cloud Messaging - delivers pushes via Google Play services.
|
||||
// Requires android/app/google-services.json at build time.
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
@@ -26,7 +26,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
val data = message.data
|
||||
|
||||
// Hand off to JS via a headless task only when the app isn't already
|
||||
// running in the foreground — HeadlessJsTaskContext throws if started
|
||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||
// while foreground. When the app is open, the main JS instance already
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
|
||||
Generated
+13
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
@@ -17,6 +18,7 @@
|
||||
"expo": "~54.0.33",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
@@ -5234,6 +5236,15 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-intent-launcher": {
|
||||
"version": "13.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz",
|
||||
"integrity": "sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -21,6 +21,7 @@
|
||||
"expo": "~54.0.33",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { generateAccountId } from '../lib/account-utils';
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only — no "@" or "/".
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
|
||||
function credentialsKey(accountId: string): string {
|
||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class JMAPClient {
|
||||
|
||||
get accountId(): string {
|
||||
if (!this._accountId) {
|
||||
throw new Error('Not authenticated — call connect() first');
|
||||
throw new Error('Not authenticated - call connect() first');
|
||||
}
|
||||
return this._accountId;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export class JMAPClient {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
// Legacy single-slot restore — kept for backward-compat tests. New code
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||
@@ -294,7 +294,7 @@ export class JMAPClient {
|
||||
// Expand the RFC 6570 level-1 template the JMAP server advertises.
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
if (!this.session?.downloadUrl) {
|
||||
throw new Error('Download URL not available — not connected');
|
||||
throw new Error('Download URL not available - not connected');
|
||||
}
|
||||
return this.session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ export async function createPushSubscription(params: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification code back to the server — the call that flips the
|
||||
* Send the verification code back to the server - the call that flips the
|
||||
* subscription from pending to active (RFC 8620 §7.2.2).
|
||||
*/
|
||||
export async function verifyPushSubscription(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export const UPDATE_REPO = 'bulwarkmail/native';
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LatestRelease {
|
||||
tag: string;
|
||||
name: string;
|
||||
htmlUrl: string;
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
apkAsset: ReleaseAsset | null;
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null;
|
||||
throw new Error(`GitHub API ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
body: string;
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
|
||||
return {
|
||||
tag: json.tag_name,
|
||||
name: json.name || json.tag_name,
|
||||
htmlUrl: json.html_url,
|
||||
publishedAt: json.published_at,
|
||||
body: json.body || '',
|
||||
apkAsset,
|
||||
};
|
||||
}
|
||||
@@ -521,7 +521,7 @@ const styles = StyleSheet.create({
|
||||
accountName: { ...typography.bodyMedium, color: colors.text },
|
||||
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
||||
|
||||
// Account menu — floating popover card under the header
|
||||
// Account menu - floating popover card under the header
|
||||
accountMenu: {
|
||||
marginHorizontal: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Download, X } from 'lucide-react-native';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
|
||||
export function UpdateBanner(): React.ReactElement | null {
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const installing = useUpdatesStore((s) => s.installing);
|
||||
const installLatest = useUpdatesStore((s) => s.installLatest);
|
||||
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
|
||||
if (!hasUpdate()) return null;
|
||||
if (cachedLatest && dismissedTag === cachedLatest.tag) return null;
|
||||
if (!cachedLatest?.apkAsset) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<Download size={16} color={colors.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>Update available</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
v{cachedLatest.tag} is ready to install.
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.installButton}
|
||||
onPress={() => void installLatest()}
|
||||
disabled={installing}
|
||||
>
|
||||
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
|
||||
<X size={14} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: colors.primaryForeground },
|
||||
subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 },
|
||||
installButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
installText: { ...typography.captionMedium, color: colors.primaryForeground },
|
||||
dismiss: { padding: 4 },
|
||||
});
|
||||
@@ -56,7 +56,7 @@ export function AgendaView({
|
||||
day.setDate(start.getDate() + i);
|
||||
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
|
||||
// All-day events first within a day, then by start time, then title
|
||||
// (stable tie-break) — mirrors the comparator the webmail uses for
|
||||
// (stable tie-break) - mirrors the comparator the webmail uses for
|
||||
// packing week segments and keeps multi-event days predictable.
|
||||
if (a.showWithoutTime !== b.showWithoutTime) {
|
||||
return a.showWithoutTime ? -1 : 1;
|
||||
|
||||
@@ -84,7 +84,7 @@ export function WeekView({
|
||||
? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs
|
||||
: 0;
|
||||
|
||||
// Timed grid excludes events that fill the full day on that day — they're
|
||||
// Timed grid excludes events that fill the full day on that day - they're
|
||||
// already promoted to the all-day strip above.
|
||||
const layoutsByDay = React.useMemo(() => {
|
||||
return weekDays.map((day) => {
|
||||
@@ -170,7 +170,7 @@ export function WeekView({
|
||||
{weekDays.map((day) => (
|
||||
<View key={day.toISOString()} style={styles.allDayCol} />
|
||||
))}
|
||||
{/* Segments overlay — multi-day events span their date range */}
|
||||
{/* Segments overlay - multi-day events span their date range */}
|
||||
<View style={styles.allDayOverlay} pointerEvents="box-none">
|
||||
{allDaySegments.map((segment) => {
|
||||
const color = getEventColor(segment.event, calendars);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Session {
|
||||
}
|
||||
|
||||
const MOCK_SESSIONS: Session[] = [
|
||||
{ id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export function NotificationSettings() {
|
||||
<View style={styles.container}>
|
||||
<SettingsSection
|
||||
title="Push Notifications"
|
||||
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing — never mail content."
|
||||
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing - never mail content."
|
||||
>
|
||||
<View style={styles.pushCard}>
|
||||
<View style={styles.pushHeader}>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||
import { useUpdatesStore } from '../../stores/updates-store';
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return 'never';
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
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 error = useUpdatesStore((s) => s.error);
|
||||
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const autoCheck = useUpdatesStore((s) => s.autoCheck);
|
||||
const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck);
|
||||
const checkNow = useUpdatesStore((s) => s.checkNow);
|
||||
const installLatest = useUpdatesStore((s) => s.installLatest);
|
||||
const currentVersion = useUpdatesStore((s) => s.currentVersion);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const updateAvailable = hasUpdate();
|
||||
const apkAsset = cachedLatest?.apkAsset ?? null;
|
||||
|
||||
const onInstall = () => {
|
||||
if (!apkAsset) {
|
||||
if (cachedLatest?.htmlUrl) {
|
||||
void Linking.openURL(cachedLatest.htmlUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void installLatest();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="App updates" description="Check for new versions and install them.">
|
||||
<SettingItem label="Current version">
|
||||
<Text style={styles.value}>v{currentVersion()}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Latest version"
|
||||
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
|
||||
>
|
||||
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '-'}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Last checked"
|
||||
description={error ? `Last error: ${error}` : undefined}
|
||||
>
|
||||
<Text style={styles.value}>{formatTime(lastCheckedAt)}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Check automatically" description="Check for updates every few hours when the app starts.">
|
||||
<ToggleSwitch checked={autoCheck} onChange={setAutoCheck} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={updateAvailable ? 'Update available' : 'Check for updates'}
|
||||
description={updateAvailable
|
||||
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
||||
: undefined}
|
||||
>
|
||||
{updateAvailable ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={onInstall}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => void checkNow({ force: true })}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? 'Checking…' : 'Check now'}
|
||||
</Button>
|
||||
)}
|
||||
</SettingItem>
|
||||
|
||||
{cachedLatest?.body ? (
|
||||
<View style={styles.notesBox}>
|
||||
<Text style={styles.notesTitle}>Release notes</Text>
|
||||
<Text style={styles.notesBody} numberOfLines={20}>
|
||||
{cachedLatest.body.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
value: { ...typography.bodyMedium, color: colors.text },
|
||||
notesBox: {
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
||||
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { useVacationStore } from '../../stores/vacation-store';
|
||||
function toJmapDate(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" — normalize to "YYYY-MM-DDTHH:MM:SS".
|
||||
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" - normalize to "YYYY-MM-DDTHH:MM:SS".
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
|
||||
const dateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(trimmed);
|
||||
if (dateOnly) return `${trimmed}T00:00:00`;
|
||||
|
||||
@@ -14,7 +14,7 @@ function ev(partial: Partial<CalendarEvent>): CalendarEvent {
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
describe('expandRecurringEvents — daily', () => {
|
||||
describe('expandRecurringEvents - daily', () => {
|
||||
it('expands a daily rule with count', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
|
||||
const result = expandRecurringEvents(
|
||||
@@ -52,9 +52,9 @@ describe('expandRecurringEvents — daily', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — weekly', () => {
|
||||
describe('expandRecurringEvents - weekly', () => {
|
||||
it('expands a weekly rule on specific byDay values', () => {
|
||||
// April 6, 2026 is a Monday — expand Mon/Wed for two weeks.
|
||||
// April 6, 2026 is a Monday - expand Mon/Wed for two weeks.
|
||||
const rule: RecurrenceRule = {
|
||||
frequency: 'weekly',
|
||||
byDay: [{ day: 'mo' }, { day: 'we' }],
|
||||
@@ -74,7 +74,7 @@ describe('expandRecurringEvents — weekly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — monthly', () => {
|
||||
describe('expandRecurringEvents - monthly', () => {
|
||||
it('expands monthly on the 6th', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
|
||||
const result = expandRecurringEvents(
|
||||
@@ -108,7 +108,7 @@ describe('expandRecurringEvents — monthly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — overrides', () => {
|
||||
describe('expandRecurringEvents - overrides', () => {
|
||||
it('skips occurrences marked excluded', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
|
||||
const event = ev({
|
||||
@@ -146,7 +146,7 @@ describe('expandRecurringEvents — overrides', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — non-recurring passthrough', () => {
|
||||
describe('expandRecurringEvents - non-recurring passthrough', () => {
|
||||
it('returns non-recurring events unchanged', () => {
|
||||
const single = ev({ id: 'one', recurrenceRules: undefined });
|
||||
const result = expandRecurringEvents(
|
||||
@@ -159,7 +159,7 @@ describe('expandRecurringEvents — non-recurring passthrough', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — range filtering', () => {
|
||||
describe('expandRecurringEvents - range filtering', () => {
|
||||
it('returns occurrences only within the requested window', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 30 };
|
||||
const result = expandRecurringEvents(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Two avatar schemes, ported verbatim from the webmail:
|
||||
//
|
||||
// 1. Account avatar (account-utils.ts) — 12-color fixed palette, hashed from
|
||||
// 1. Account avatar (account-utils.ts) - 12-color fixed palette, hashed from
|
||||
// the account's email. Used for the sidebar account block and account
|
||||
// switcher.
|
||||
//
|
||||
// 2. Email-row avatar (components/ui/avatar.tsx) — HSL hue from a hash of
|
||||
// 2. Email-row avatar (components/ui/avatar.tsx) - HSL hue from a hash of
|
||||
// name or email. Used for per-message avatars in the email list.
|
||||
//
|
||||
// Both variants also define `getInitials` with slightly different rules, so we
|
||||
@@ -68,7 +68,7 @@ export function getEmailInitials(name: string, email?: string): string {
|
||||
// logo from DuckDuckGo. The webmail goes through `/api/favicon?domain=...` as
|
||||
// a caching proxy; native has no CORS constraint, so we hit DuckDuckGo direct.
|
||||
|
||||
// Multi-part TLDs (trimmed to the webmail client-side list — not the giant
|
||||
// Multi-part TLDs (trimmed to the webmail client-side list - not the giant
|
||||
// server PSL). Covers the common cases so subdomains resolve to the right
|
||||
// registrable domain (e.g. newsletter.example.co.uk → example.co.uk).
|
||||
const MULTI_PART_TLDS = new Set([
|
||||
@@ -100,7 +100,7 @@ const MULTI_PART_TLDS = new Set([
|
||||
'co.ke', 'or.ke', 'ac.ke', 'go.ke', 'ne.ke',
|
||||
]);
|
||||
|
||||
// Personal mail providers — the favicon here is the mail provider's logo, not
|
||||
// Personal mail providers - the favicon here is the mail provider's logo, not
|
||||
// anything specific to the sender, so we skip it.
|
||||
const PERSONAL_DOMAINS = new Set([
|
||||
'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com',
|
||||
|
||||
@@ -87,7 +87,7 @@ export function eventsOnDay(events: CalendarEvent[], day: Date): CalendarEvent[]
|
||||
// O(days × events) with two parseISO calls per event per day. For ~200 events
|
||||
// that's ~16k parseISO calls per month render, redone on every swipe.
|
||||
// `buildEventDayIndex` parses each event's start/end once, then walks only the
|
||||
// days that event actually touches — typically 1 day. Result is a map from
|
||||
// days that event actually touches - typically 1 day. Result is a map from
|
||||
// local-date key (yyyy-MM-dd) to the events on that day.
|
||||
export type EventDayIndex = Map<string, CalendarEvent[]>;
|
||||
|
||||
@@ -104,7 +104,7 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
|
||||
const start = getEventStartDate(event);
|
||||
const end = getEventDisplayEndDate(event);
|
||||
let day = startOfDay(start);
|
||||
// Cap at 366 days: matches the original semantics (end > dayStart) — we
|
||||
// Cap at 366 days: matches the original semantics (end > dayStart) - we
|
||||
// include day while day < end. Safety bound guards against malformed
|
||||
// multi-year events that would otherwise blow up the loop.
|
||||
let safety = 0;
|
||||
@@ -193,7 +193,7 @@ export function getTimedEventBoundsForDay(
|
||||
// ─── Timed events that fill the day ──────────────────────
|
||||
// Events shown in the timed grid that effectively span 00:00–24:00 on the
|
||||
// given day get promoted to the all-day strip in the week/day views (matches
|
||||
// webmail behavior — keeps the timed grid usable when an event covers the day).
|
||||
// webmail behavior - keeps the timed grid usable when an event covers the day).
|
||||
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
|
||||
const bounds = getTimedEventBoundsForDay(event, day);
|
||||
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
|
||||
@@ -315,7 +315,7 @@ export function buildTimedFullDayWeekSegments(
|
||||
// begins after every prior event has ended. Within a cluster every event gets
|
||||
// the same `totalColumns`; across clusters, events get to use the full width
|
||||
// they actually need. The previous global packing forced an event with no
|
||||
// neighbors at 17:00 to share columns with a 9am cluster — visible squish.
|
||||
// neighbors at 17:00 to share columns with a 9am cluster - visible squish.
|
||||
export interface TimedEventLayout {
|
||||
event: CalendarEvent;
|
||||
column: number;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// so inline <script>, inline event handlers, and external resources are
|
||||
// blocked by the engine itself. This is the primary boundary.
|
||||
// 2. `onShouldStartLoadWithRequest` refuses any navigation except the
|
||||
// initial about:blank doc — links are opened in the OS browser.
|
||||
// initial about:blank doc - links are opened in the OS browser.
|
||||
// 3. This module pre-strips known-dangerous tags and on* attributes so the
|
||||
// rendered DOM is also clean. Regex stripping is imperfect; it is here
|
||||
// as belt-and-suspenders, not the primary defense.
|
||||
@@ -82,7 +82,7 @@ export function plainTextToSafeHtml(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Base styles for HTML emails — mirrors the webmail iframe body 1:1.
|
||||
// Base styles for HTML emails - mirrors the webmail iframe body 1:1.
|
||||
// Emails are authored for light mode; we render them true-to-life and apply a
|
||||
// filter inversion trick for dark mode (unless the email has native dark
|
||||
// support via @media (prefers-color-scheme: dark)).
|
||||
@@ -171,7 +171,7 @@ export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): str
|
||||
|
||||
// Strict CSP. Inline <script> and handlers from the email are stripped by
|
||||
// `stripDangerousTags`, so script-src only needs to allow our own injected
|
||||
// measurement bridge — 'unsafe-inline' is the minimum that lets RN WebView's
|
||||
// measurement bridge - 'unsafe-inline' is the minimum that lets RN WebView's
|
||||
// injected script run on Android (where page CSP can apply to evaluateJs).
|
||||
const imgSrc = blockRemoteImages ? "img-src data: cid:" : "img-src data: cid: https: http:";
|
||||
const csp = [
|
||||
@@ -286,7 +286,7 @@ function blockRemoteImageSrcs(html: string): string {
|
||||
|
||||
// Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts).
|
||||
// Returns false when the HTML is an auto-generated minimal wrapper around plain
|
||||
// text (no <br>, no links, no rich tags) — the caller should fall back to the
|
||||
// text (no <br>, no links, no rich tags) - the caller should fall back to the
|
||||
// textBody in that case, since the server-side HTML often collapses newlines.
|
||||
const MEANINGFUL_HTML_RE =
|
||||
/<(?:table|img|style|b|strong|i|em|u|font|h[1-6]|ul|ol|blockquote|br)\b|<a\b[^>]*\bhref=|<(?:div|span|p)\b[^>]*\bstyle=/i;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import * as FileSystemLegacy from 'expo-file-system/legacy';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import type { ReleaseAsset } from '../api/updates';
|
||||
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
|
||||
|
||||
export async function downloadAndInstallApk(asset: ReleaseAsset): 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);
|
||||
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: contentUri,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
flags: FLAG_GRANT_READ_URI_PERMISSION | FLAG_ACTIVITY_NEW_TASK,
|
||||
});
|
||||
}
|
||||
@@ -29,7 +29,7 @@ interface BulwarkFcmNative {
|
||||
}
|
||||
|
||||
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
|
||||
// short-lived headless JS runtime — keep it fast, catch all errors, and always
|
||||
// short-lived headless JS runtime - keep it fast, catch all errors, and always
|
||||
// resolve so the native service can release its wake lock.
|
||||
export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -18,11 +18,11 @@ const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
|
||||
// Hosted relay so users don't need to run their own Firebase project. The
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes — no mail content.
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
|
||||
// Power users can override this from the settings screen.
|
||||
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
|
||||
|
||||
// Types the mobile app wants StateChange pings for. Submission is excluded —
|
||||
// Types the mobile app wants StateChange pings for. Submission is excluded -
|
||||
// outgoing mail state changes don't belong in a user-visible push.
|
||||
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
|
||||
|
||||
@@ -37,7 +37,7 @@ function getNative(): BulwarkFcmNative | null {
|
||||
}
|
||||
|
||||
export interface PushSetupParams {
|
||||
// Optional — falls back to the hosted relay if omitted.
|
||||
// Optional - falls back to the hosted relay if omitted.
|
||||
relayBaseUrl?: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export async function setupPushNotifications(
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
* FCM token. Never throws — callers treat teardown as best effort.
|
||||
* FCM token. Never throws - callers treat teardown as best effort.
|
||||
*/
|
||||
export async function teardownPushNotifications(): Promise<void> {
|
||||
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
@@ -266,7 +266,7 @@ export interface NotificationTapPayload {
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
// Returns — and clears — any pending "notification tap" that launched the app
|
||||
// Returns - and clears - any pending "notification tap" that launched the app
|
||||
// before JS was ready to handle it. Subsequent taps while running are delivered
|
||||
// via addNotificationTapListener.
|
||||
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function parseVersion(v: string): [number, number, number] {
|
||||
const cleaned = v.replace(/^v/, '').split(/[-+]/)[0];
|
||||
const parts = cleaned.split('.').map((n) => parseInt(n, 10));
|
||||
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [a1, a2, a3] = parseVersion(a);
|
||||
const [b1, b2, b3] = parseVersion(b);
|
||||
if (a1 !== b1) return a1 - b1;
|
||||
if (a2 !== b2) return a2 - b2;
|
||||
return a3 - b3;
|
||||
}
|
||||
|
||||
export function isNewer(latest: string, current: string): boolean {
|
||||
return compareVersions(latest, current) > 0;
|
||||
}
|
||||
@@ -210,7 +210,7 @@ export default function CalendarScreen() {
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
if (!action) return;
|
||||
// For the first pass, treat all scopes as "all" — proper override
|
||||
// For the first pass, treat all scopes as "all" - proper override
|
||||
// handling lives in the store's update/delete path against originalId.
|
||||
// TODO: implement "this only" via recurrenceOverrides write, and
|
||||
// "this and following" via excludedRecurrenceRules + new master.
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function ContactsScreen() {
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list — single unnamed section keeps SectionList rendering simple.
|
||||
// Flat list - single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
|
||||
@@ -510,7 +510,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Compose FAB — matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||
<Pressable
|
||||
onPress={onComposePress}
|
||||
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
||||
@@ -749,7 +749,7 @@ function TriToggle({
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
|
||||
// Header — matches web mobile-header: h-14 (56px), px-4, border-b
|
||||
// Header - matches web mobile-header: h-14 (56px), px-4, border-b
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -770,7 +770,7 @@ const styles = StyleSheet.create({
|
||||
height: 28,
|
||||
},
|
||||
|
||||
// Search — matches web search bar styling
|
||||
// Search - matches web search bar styling
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1026,7 +1026,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
// Email list — matches web email-list-item
|
||||
// Email list - matches web email-list-item
|
||||
listContent: { paddingBottom: 100 },
|
||||
emailRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -1111,7 +1111,7 @@ const styles = StyleSheet.create({
|
||||
tagText: { ...typography.small, fontWeight: '500' },
|
||||
separator: { height: 0 }, // borders are on rows now
|
||||
|
||||
// Compose FAB — matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
|
||||
// Compose FAB - matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: spacing.lg, // right-4
|
||||
@@ -1119,7 +1119,7 @@ const styles = StyleSheet.create({
|
||||
width: componentSizes.fab, // 56px (h-14)
|
||||
height: componentSizes.fab, // 56px (w-14)
|
||||
borderRadius: radius.full, // rounded-full (circle)
|
||||
backgroundColor: colors.text, // white — matches webmail mobile FAB
|
||||
backgroundColor: colors.text, // white - matches webmail mobile FAB
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 6,
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
||||
Modal, useWindowDimensions, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Paperclip,
|
||||
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||
Inbox, Send, File as FileIcon, Ban, Folder,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import EmailBodyView from '../components/EmailBodyView';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import type { Email } from '../api/types';
|
||||
import { setEmailKeywords } from '../api/email';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import type { Email, Mailbox } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
||||
const lower = name.toLowerCase();
|
||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
||||
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
|
||||
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
||||
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
|
||||
if (role === 'archive' || lower.includes('archive')) return Archive;
|
||||
return Folder;
|
||||
}
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
|
||||
|
||||
function formatHeaderDate(iso: string): string {
|
||||
@@ -43,16 +60,19 @@ function plainTextBody(email: Email): string {
|
||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const { emailId, subject: subjectParam } = route.params;
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
|
||||
const markRead = useEmailStore((s) => s.markRead);
|
||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
|
||||
const [email, setEmail] = React.useState<Email | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -78,16 +98,46 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
}, [emailId, getEmailDetail, markRead]);
|
||||
|
||||
const starred = !!email?.keywords?.$flagged;
|
||||
const unread = !!email && !email.keywords?.$seen;
|
||||
|
||||
const archiveMailbox = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive'),
|
||||
[mailboxes],
|
||||
);
|
||||
const junkMailbox = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
|
||||
[mailboxes],
|
||||
);
|
||||
const inboxMailbox = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'inbox'),
|
||||
[mailboxes],
|
||||
);
|
||||
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
|
||||
|
||||
const updateLocalKeywords = (next: Record<string, boolean>) => {
|
||||
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
|
||||
};
|
||||
|
||||
const onToggleStar = () => {
|
||||
if (!email) return;
|
||||
void toggleStar(email.id, !starred);
|
||||
setEmail({
|
||||
...email,
|
||||
keywords: {
|
||||
...email.keywords,
|
||||
...(starred ? {} : { $flagged: true }),
|
||||
},
|
||||
});
|
||||
const next = { ...email.keywords };
|
||||
if (starred) delete next.$flagged;
|
||||
else next.$flagged = true;
|
||||
updateLocalKeywords(next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
};
|
||||
|
||||
const onToggleUnread = () => {
|
||||
if (!email) return;
|
||||
if (unread) {
|
||||
void markRead(email.id);
|
||||
updateLocalKeywords({ ...email.keywords, $seen: true });
|
||||
} else {
|
||||
const next = { ...email.keywords };
|
||||
delete next.$seen;
|
||||
updateLocalKeywords(next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
@@ -98,6 +148,35 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const onArchive = () => {
|
||||
if (!email || !currentMailboxId || !archiveMailbox) return;
|
||||
if (currentMailboxId === archiveMailbox.id) return;
|
||||
void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const onToggleSpam = () => {
|
||||
if (!email || !currentMailboxId) return;
|
||||
setMoreMenuOpen(false);
|
||||
if (isInJunk) {
|
||||
const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId);
|
||||
if (!target) return;
|
||||
void moveToMailbox(email.id, currentMailboxId, target.id);
|
||||
} else {
|
||||
if (!junkMailbox) return;
|
||||
void moveToMailbox(email.id, currentMailboxId, junkMailbox.id);
|
||||
}
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const onMoveToMailbox = (toId: string) => {
|
||||
if (!email || !currentMailboxId || toId === currentMailboxId) return;
|
||||
setMoveMenuOpen(false);
|
||||
setMoreMenuOpen(false);
|
||||
void moveToMailbox(email.id, currentMailboxId, toId);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const navigateCompose = (mode: 'reply' | 'replyAll' | 'forward') => {
|
||||
if (!email) return;
|
||||
const from = email.from?.[0];
|
||||
@@ -119,6 +198,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const from = email?.from?.[0];
|
||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||
|
||||
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
|
||||
// back arrow + side padding reserves ~64px. Drop optional buttons on narrow screens.
|
||||
const showMarkUnread = windowWidth >= 340;
|
||||
const showArchive = windowWidth >= 400 && !!archiveMailbox;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Toolbar */}
|
||||
@@ -132,6 +216,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
label="Delete"
|
||||
onPress={onDelete}
|
||||
/>
|
||||
{showArchive && (
|
||||
<ToolbarButton
|
||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
||||
label="Archive"
|
||||
onPress={onArchive}
|
||||
/>
|
||||
)}
|
||||
{showMarkUnread && (
|
||||
<ToolbarButton
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpen size={18} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Mail size={18} color={colors.textSecondary} />
|
||||
)
|
||||
}
|
||||
label={unread ? 'Read' : 'Unread'}
|
||||
onPress={onToggleUnread}
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<Star
|
||||
@@ -146,6 +250,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<ToolbarButton
|
||||
icon={<MoreVertical size={18} color={colors.textSecondary} />}
|
||||
label="More"
|
||||
onPress={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -203,7 +308,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
) : null}
|
||||
<Text style={styles.senderRecipients} numberOfLines={1}>
|
||||
<Text style={styles.senderRecipientsLabel}>to </Text>
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '—'}
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -262,10 +367,243 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<MoreMenuSheet
|
||||
visible={moreMenuOpen}
|
||||
onClose={() => setMoreMenuOpen(false)}
|
||||
unread={unread}
|
||||
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
|
||||
canMarkUnread={!showMarkUnread}
|
||||
canMove={mailboxes.length > 0}
|
||||
showSpam={!!junkMailbox || isInJunk}
|
||||
isInJunk={isInJunk}
|
||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
|
||||
onToggleSpam={onToggleSpam}
|
||||
/>
|
||||
|
||||
<MoveMenuSheet
|
||||
visible={moveMenuOpen}
|
||||
onClose={() => setMoveMenuOpen(false)}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={onMoveToMailbox}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
unread: boolean;
|
||||
canArchive: boolean;
|
||||
canMarkUnread: boolean;
|
||||
canMove: boolean;
|
||||
showSpam: boolean;
|
||||
isInJunk: boolean;
|
||||
onArchive: () => void;
|
||||
onToggleUnread: () => void;
|
||||
onMove: () => void;
|
||||
onToggleSpam: () => void;
|
||||
}
|
||||
|
||||
function MoreMenuSheet({
|
||||
visible, onClose, unread, canArchive, canMarkUnread, canMove,
|
||||
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
|
||||
}: MoreMenuSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(400)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 400, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>More actions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{canArchive && (
|
||||
<MoreMenuItem
|
||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
||||
label="Archive"
|
||||
onPress={onArchive}
|
||||
/>
|
||||
)}
|
||||
{canMarkUnread && (
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpen size={18} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Mail size={18} color={colors.textSecondary} />
|
||||
)
|
||||
}
|
||||
label={unread ? 'Mark as read' : 'Mark as unread'}
|
||||
onPress={onToggleUnread}
|
||||
/>
|
||||
)}
|
||||
{canMove && (
|
||||
<MoreMenuItem
|
||||
icon={<FolderInput size={18} color={colors.textSecondary} />}
|
||||
label="Move to folder…"
|
||||
onPress={onMove}
|
||||
trailing={<ChevronRight size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{showSpam && (
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
isInJunk ? (
|
||||
<ShieldCheck size={18} color={colors.success} />
|
||||
) : (
|
||||
<ShieldAlert size={18} color={colors.error} />
|
||||
)
|
||||
}
|
||||
label={isInJunk ? 'Not spam' : 'Mark as spam'}
|
||||
onPress={onToggleSpam}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoveMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
mailboxes: Mailbox[];
|
||||
currentMailboxId: string | null;
|
||||
onPick: (id: string) => void;
|
||||
}
|
||||
|
||||
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const visibleNodes = React.useMemo(() => {
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const expanded = new Set<string>();
|
||||
const collect = (nodes: MailboxNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.children.length > 0) {
|
||||
expanded.add(n.id);
|
||||
collect(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(tree);
|
||||
return flattenVisible(tree, expanded);
|
||||
}, [mailboxes]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
styles.sheetTall,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Move to folder</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
|
||||
return (
|
||||
<Pressable
|
||||
key={node.id}
|
||||
onPress={canTarget ? () => onPick(node.id) : undefined}
|
||||
disabled={!canTarget}
|
||||
style={({ pressed }) => [
|
||||
styles.moveRow,
|
||||
pressed && canTarget && styles.moveRowPressed,
|
||||
{ paddingLeft: spacing.lg + node.depth * 16 },
|
||||
]}
|
||||
>
|
||||
<Icon size={16} color={canTarget ? colors.textSecondary : colors.textMuted} />
|
||||
<Text
|
||||
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreMenuItem({
|
||||
icon, label, onPress, trailing,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.moreItem, pressed && styles.moreItemPressed]}
|
||||
>
|
||||
<View style={styles.moreItemIcon}>{icon}</View>
|
||||
<Text style={styles.moreItemLabel}>{label}</Text>
|
||||
{trailing}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon, label, onPress,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
|
||||
@@ -324,7 +662,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
minHeight: 44,
|
||||
},
|
||||
@@ -487,4 +825,94 @@ const styles = StyleSheet.create({
|
||||
bottomBarLabelDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
|
||||
// Bottom sheet (More menu / Move folder picker)
|
||||
sheetOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetOverlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: colors.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetTall: {
|
||||
maxHeight: '75%',
|
||||
},
|
||||
sheetHandle: {
|
||||
alignSelf: 'center',
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
|
||||
// More menu item
|
||||
moreItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 48,
|
||||
},
|
||||
moreItemPressed: { backgroundColor: colors.surfaceHover },
|
||||
moreItemIcon: {
|
||||
width: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
moreItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Move folder row
|
||||
moveRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
moveRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
moveRowLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
moveRowLabelDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ArrowLeft, LogOut, Settings, ChevronRight,
|
||||
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
@@ -16,13 +16,14 @@ import { IdentitySettings } from '../components/settings/IdentitySettings';
|
||||
import { VacationSettings } from '../components/settings/VacationSettings';
|
||||
import { FolderSettings } from '../components/settings/FolderSettings';
|
||||
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
|
||||
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
|
||||
|
||||
type Tab =
|
||||
| 'appearance' | 'email' | 'notifications'
|
||||
| 'account' | 'security' | 'identities' | 'encryption' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'themes' | 'plugins' | 'advanced';
|
||||
| 'themes' | 'plugins' | 'updates' | 'advanced';
|
||||
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
|
||||
@@ -64,6 +65,7 @@ const TABS: TabDef[] = [
|
||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
|
||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'system', implemented: true },
|
||||
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
|
||||
];
|
||||
|
||||
@@ -74,6 +76,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
folders: FolderSettings,
|
||||
updates: UpdatesSettings,
|
||||
advanced: AdvancedSettings,
|
||||
};
|
||||
|
||||
@@ -128,7 +131,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
@@ -140,7 +143,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab list — matches webmail mobile: flat grouped list, no cards */}
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.tabList}>
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
@@ -195,7 +198,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Logout — matches webmail: border-t, destructive text, icon + label */}
|
||||
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={onLogout}
|
||||
@@ -216,7 +219,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// Header — webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -256,7 +259,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group divider — webmail: mx-5 my-2 border-t border-border
|
||||
// Group divider - webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
@@ -264,7 +267,7 @@ const styles = StyleSheet.create({
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group header — webmail: px-5 pt-3 pb-1.5
|
||||
// Group header - webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
@@ -278,7 +281,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Tab item — webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -300,7 +303,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// Experimental badge — webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
@@ -331,7 +334,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout — webmail: border-t border-border, px-5 py-3, destructive text
|
||||
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
|
||||
@@ -104,7 +104,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (serverUrl, username, password, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Adding an additional account — snapshot/reset feature stores so the
|
||||
// Adding an additional account - snapshot/reset feature stores so the
|
||||
// new account starts with a clean slate.
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
@@ -221,7 +221,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing — evict stale entry and surface error
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
@@ -250,7 +250,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
try {
|
||||
// Wait for persisted caches to finish hydrating from AsyncStorage.
|
||||
// Otherwise we read empty defaults and bounce the user back to the
|
||||
// login screen — and the feature stores don't have their cached data
|
||||
// login screen - and the feature stores don't have their cached data
|
||||
// yet when refetchFeatureStores() checks currentMailboxId / loadedRange
|
||||
// at the end of this function.
|
||||
await Promise.all([
|
||||
|
||||
@@ -122,7 +122,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before };
|
||||
const ids = calendars.map((c) => c.id);
|
||||
if (ids.length === 0) {
|
||||
// Calendars haven't loaded yet — fetch them, then events.
|
||||
// Calendars haven't loaded yet - fetch them, then events.
|
||||
await get().fetchCalendars();
|
||||
}
|
||||
const calendarIdsAfter = get().calendars.map((c) => c.id);
|
||||
|
||||
@@ -114,7 +114,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
selectMailbox: async (mailboxId) => {
|
||||
// Reset search/filters when switching mailbox — matches webmail behavior.
|
||||
// Reset search/filters when switching mailbox - matches webmail behavior.
|
||||
set({
|
||||
currentMailboxId: mailboxId,
|
||||
loading: true,
|
||||
@@ -157,7 +157,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
refreshEmails: async () => {
|
||||
const { currentMailboxId, searchQuery, filters } = get();
|
||||
if (!currentMailboxId) return;
|
||||
// Keep existing emails visible while refreshing — swap atomically once
|
||||
// Keep existing emails visible while refreshing - swap atomically once
|
||||
// the new list is ready so pull-to-refresh doesn't flash an empty list.
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Constants from 'expo-constants';
|
||||
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
||||
import { isNewer } from '../lib/version-compare';
|
||||
import { downloadAndInstallApk } from '../lib/install-update';
|
||||
|
||||
const STORAGE_KEY = 'webmail:updates:v1';
|
||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
interface PersistedUpdates {
|
||||
autoCheck: boolean;
|
||||
lastCheckedAt: number;
|
||||
cachedLatest: LatestRelease | null;
|
||||
dismissedTag: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedUpdates = {
|
||||
autoCheck: true,
|
||||
lastCheckedAt: 0,
|
||||
cachedLatest: null,
|
||||
dismissedTag: null,
|
||||
};
|
||||
|
||||
export interface UpdatesState extends PersistedUpdates {
|
||||
hydrated: boolean;
|
||||
checking: boolean;
|
||||
installing: boolean;
|
||||
error: string | null;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
setAutoCheck: (enabled: boolean) => void;
|
||||
checkNow: (opts?: { force?: boolean }) => Promise<void>;
|
||||
installLatest: () => Promise<void>;
|
||||
dismissCurrent: () => void;
|
||||
currentVersion: () => string;
|
||||
hasUpdate: () => boolean;
|
||||
}
|
||||
|
||||
function persist(state: PersistedUpdates): void {
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)).catch((err) => {
|
||||
console.warn('[updates-store] persist failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
...DEFAULT_PERSISTED,
|
||||
hydrated: false,
|
||||
checking: false,
|
||||
installing: false,
|
||||
error: null,
|
||||
|
||||
hydrate: async () => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedUpdates>;
|
||||
set({ ...DEFAULT_PERSISTED, ...parsed, hydrated: true });
|
||||
} else {
|
||||
set({ hydrated: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[updates-store] hydrate failed', err);
|
||||
set({ hydrated: true });
|
||||
}
|
||||
},
|
||||
|
||||
setAutoCheck: (enabled) => {
|
||||
set({ autoCheck: enabled });
|
||||
const s = get();
|
||||
persist({
|
||||
autoCheck: enabled,
|
||||
lastCheckedAt: s.lastCheckedAt,
|
||||
cachedLatest: s.cachedLatest,
|
||||
dismissedTag: s.dismissedTag,
|
||||
});
|
||||
},
|
||||
|
||||
checkNow: async (opts) => {
|
||||
const s = get();
|
||||
if (s.checking) return;
|
||||
const now = Date.now();
|
||||
if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return;
|
||||
set({ checking: true, error: null });
|
||||
try {
|
||||
const latest = await fetchLatestRelease();
|
||||
const next: PersistedUpdates = {
|
||||
autoCheck: s.autoCheck,
|
||||
lastCheckedAt: now,
|
||||
cachedLatest: latest,
|
||||
dismissedTag: s.dismissedTag,
|
||||
};
|
||||
set({ ...next, checking: false });
|
||||
persist(next);
|
||||
} catch (err) {
|
||||
set({ checking: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
installLatest: async () => {
|
||||
const s = get();
|
||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||
set({ installing: true, error: null });
|
||||
try {
|
||||
await downloadAndInstallApk(s.cachedLatest.apkAsset);
|
||||
set({ installing: false });
|
||||
} catch (err) {
|
||||
set({ installing: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dismissCurrent: () => {
|
||||
const s = get();
|
||||
const tag = s.cachedLatest?.tag ?? null;
|
||||
set({ dismissedTag: tag });
|
||||
persist({
|
||||
autoCheck: s.autoCheck,
|
||||
lastCheckedAt: s.lastCheckedAt,
|
||||
cachedLatest: s.cachedLatest,
|
||||
dismissedTag: tag,
|
||||
});
|
||||
},
|
||||
|
||||
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
|
||||
|
||||
hasUpdate: () => {
|
||||
const s = get();
|
||||
if (!s.cachedLatest) return false;
|
||||
const current = Constants.expoConfig?.version ?? '0.0.0';
|
||||
return isNewer(s.cachedLatest.tag, current);
|
||||
},
|
||||
}));
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
// Qui Dark Theme — exact values from Bulwark Webmail's builtin-themes.ts
|
||||
// Qui Dark Theme - exact values from Bulwark Webmail's builtin-themes.ts
|
||||
export const colors = {
|
||||
// Primary brand (Qui theme)
|
||||
primary: '#3b82f6',
|
||||
@@ -69,7 +69,7 @@ export const colors = {
|
||||
orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark
|
||||
},
|
||||
|
||||
// Tag colors — from KEYWORD_PALETTE in stores/settings-store.ts
|
||||
// Tag colors - from KEYWORD_PALETTE in stores/settings-store.ts
|
||||
// dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400
|
||||
tags: {
|
||||
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
|
||||
@@ -87,7 +87,7 @@ export const colors = {
|
||||
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
|
||||
},
|
||||
|
||||
// Navigation — matches web nav-rail
|
||||
// Navigation - matches web nav-rail
|
||||
navActive: '#3b82f6',
|
||||
navInactive: '#71717a', // --color-muted-foreground
|
||||
navBadge: '#ef4444', // red-500
|
||||
@@ -145,18 +145,18 @@ export const typography = {
|
||||
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl
|
||||
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl
|
||||
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg
|
||||
// Body = text-sm (14px) — the web uses text-sm for almost all content
|
||||
// Body = text-sm (14px) - the web uses text-sm for almost all content
|
||||
body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
|
||||
bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 },
|
||||
bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 },
|
||||
bodyBold: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 },
|
||||
// Base = text-base (16px) — used for email content body
|
||||
// Base = text-base (16px) - used for email content body
|
||||
base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
|
||||
baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 },
|
||||
// Caption = text-xs (12px)
|
||||
caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 },
|
||||
captionMedium: { fontSize: 12, fontWeight: '500' as const, lineHeight: 16 },
|
||||
// Small = 10px — for tags, badges, tab labels
|
||||
// Small = 10px - for tags, badges, tab labels
|
||||
small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user