Compare commits

...
8 Commits
38 changed files with 1086 additions and 142 deletions
+46 -3
View File
@@ -28,9 +28,31 @@ jobs:
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 17
cache: gradle
- uses: android-actions/setup-android@v3 - 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 - name: Install JS dependencies
run: npm ci run: npm ci
@@ -54,7 +76,9 @@ jobs:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} 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_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} 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 - name: Rename APK with version
id: artifact id: artifact
@@ -72,9 +96,28 @@ jobs:
path: ${{ steps.artifact.outputs.path }} path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error 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 - 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 uses: softprops/action-gh-release@v2
with: 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 }} files: ${{ steps.artifact.outputs.path }}
+19 -1
View File
@@ -34,6 +34,8 @@ import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store'; import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store'; import { useEmailStore } from './src/stores/email-store';
import { useSettingsStore } from './src/stores/settings-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'; import { colors, spacing, typography } from './src/theme/tokens';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -64,6 +66,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0; const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
return ( return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<UpdateBanner />
<Tab.Navigator <Tab.Navigator
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
@@ -147,6 +151,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
{() => <SettingsScreen onLogout={logout} />} {() => <SettingsScreen onLogout={logout} />}
</Tab.Screen> </Tab.Screen>
</Tab.Navigator> </Tab.Navigator>
</View>
); );
} }
@@ -171,8 +176,21 @@ export default function App() {
void useSettingsStore.getState().hydrate(); 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 // 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. // notification so the user sees it regardless of RN runtime state.
React.useEffect(() => { React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => { const unsubscribe = addMessageListener((payload) => {
+60 -53
View File
@@ -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 Bring `repos/react-native/` calendar to parity with the webmail calendar at
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus `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 ## 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. 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 `fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
`handleStateChange`, no per-calendar visibility, no range tracking. `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`). `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. - No event modal, no detail view, no week/day view, no sidebar, no recurrence.
## In scope ## 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 - `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
`CalendarSidebar` toggle state). `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. 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 covered by `loadedRange`; expand the loaded range in place instead of
replacing events wholesale, so switching Month → Week doesn't drop data. 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. 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)`. - `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set). - Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
**`src/lib/calendar-utils.ts`** (new) — port from webmail's `lib/calendar-utils.ts`: **`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`:
- `parseLocalDateTime(iso)` — JSCalendar start strings are floating local time
- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts. (`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
- `parseDuration(iso8601)` — `PT1H30M` → milliseconds. - `parseDuration(iso8601)` - `PT1H30M` → milliseconds.
- `eventTimeRange(event)` — returns `{ start: Date, end: Date, allDay: boolean }` - `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }`
handling `showWithoutTime`. handling `showWithoutTime`.
- `eventsOnDay(events, day)` — day overlap check (not just start-matches-day). - `eventsOnDay(events, day)` - day overlap check (not just start-matches-day).
- `getCalendarColor(cal)` — fallback to a deterministic palette when `color` - `getCalendarColor(cal)` - fallback to a deterministic palette when `color`
is missing, matching `theme/tokens.ts` `colors.calendar.*`. 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`. 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. 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. - On mount: `fetchCalendars()`, then `ensureRange` for visible window.
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone). - 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. - 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`. - FAB opens create modal with `start = selectedDate at next half hour`.
**`src/components/calendar/MonthView.tsx`** (extract from current screen, **`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 more. Tap day → selects + scrolls agenda below. Long-press day → opens create
modal with that date. 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: `calendar-week-view.tsx` (511 lines) trimmed for mobile:
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24. - 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
- All-day strip at top. - All-day strip at top.
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among - 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. - Tap event → detail modal. Long-press empty slot → create modal pre-filled.
- Horizontal swipe → prev/next week (react-native-gesture-handler already - Horizontal swipe → prev/next week (react-native-gesture-handler already
available via react-navigation). 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. 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 `calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
days forward. Taps open detail modal. 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 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. - Header: calendar color bar, title, date/time range (or "All day"), calendar name.
- Sections: location, description (linkified), participants with RSVP status, - 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. - Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event - If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
only" / "This and following" / "All events"). 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): modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
- Title, calendar picker (color swatches), all-day toggle. - 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). - Location, description (multiline).
- Participant input with autocomplete from contacts store (port of - Participant input with autocomplete from contacts store (port of
`participant-input.tsx`, ~180 lines). `participant-input.tsx`, ~180 lines).
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly, - Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.) 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 - Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
`Alert.trigger.offset`. `Alert.trigger.offset`.
- Timezone: default to device TZ; optional override picker hidden behind - 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 - Save path handles "this instance only" override vs master update for
recurring events (write to `recurrenceOverrides[recurrenceId]`). 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 direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
edit/delete of a recurring instance. 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 port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
(reuses `SidebarDrawer.tsx`): (reuses `SidebarDrawer.tsx`):
- List of calendars with color swatch + visibility toggle + name. - List of calendars with color swatch + visibility toggle + name.
- "My calendars" vs "Subscribed" sections if `myRights` differs. - "My calendars" vs "Subscribed" sections if `myRights` differs.
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5). - Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
- Footer: "Add calendar" (deferred to Phase 6). - 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`); calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
add any missing shades. add any missing shades.
### Phase 5 — Polish ### Phase 5 - Polish
- Pull-to-refresh on each view → forces re-fetch of current window. - Pull-to-refresh on each view → forces re-fetch of current window.
- Empty states per view (no calendars yet / no events this month). - 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: 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 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. 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 JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
screen, not bolted onto calendar. 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. 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. 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. 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. 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. contacts-store integration for a follow-up.
## Dependencies to add ## 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 - `react-native-reanimated` is already in (via react-navigation); reuse for
bottom-sheet animations on `EventDetailSheet`. 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). than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
## File map (new / modified) ## File map (new / modified)
@@ -207,14 +214,14 @@ repos/react-native/src/
## Build order (suggested) ## Build order (suggested)
1. Phase 1 in full — nothing else works without real data. 1. Phase 1 in full - nothing else works without real data.
2. MonthView + EventDetailSheet (read path) — first thing user sees. 2. MonthView + EventDetailSheet (read path) - first thing user sees.
3. EventModal create path — unblocks manual testing of write path. 3. EventModal create path - unblocks manual testing of write path.
4. AgendaView + CalendarSidebarDrawer — pulls the rest of month together. 4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together.
5. WeekView — biggest UI, easiest to defer if time runs short. 5. WeekView - biggest UI, easiest to defer if time runs short.
6. EventModal edit + recurrence scope — finishes the edit surface. 6. EventModal edit + recurrence scope - finishes the edit surface.
7. Phase 5 polish. 7. Phase 5 polish.
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util 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. we stop at Phase 2.
+47
View File
@@ -0,0 +1,47 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
# Bulwark Mobile
> **Beta - work in progress.** Many features are unfinished or rough. Expect bugs, missing functionality, and breaking changes between releases. Do not rely on this for primary email yet.
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
## What works today
- Sign in to any JMAP server (e.g. Stalwart)
- Multiple accounts
- Email list, threads, compose
- Calendar (basic)
- Contacts (basic)
- Push notifications via FCM relay
- In-app sideload updates from GitHub Releases
## What's missing or rough
- iOS build is untested
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
## Run locally
```bash
npm install
npx expo start
```
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
+1 -1
View File
@@ -1 +1 @@
0.1.0 0.1.2
+1 -1
View File
@@ -166,7 +166,7 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin // The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android") 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. // Requires android/app/google-services.json at build time.
implementation(platform("com.google.firebase:firebase-bom:33.7.0")) implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging") implementation("com.google.firebase:firebase-messaging")
@@ -26,7 +26,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
val data = message.data val data = message.data
// Hand off to JS via a headless task only when the app isn't already // 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 // while foreground. When the app is open, the main JS instance already
// receives JMAP push directly, so the fcm:message event below is // receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state. // sufficient for it to refresh state.
Vendored Regular → Executable
View File
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-mobile", "name": "bulwark-mobile",
"version": "0.1.0", "version": "0.1.2",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
+4 -4
View File
@@ -11,7 +11,7 @@ import { generateAccountId } from '../lib/account-utils';
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials'; const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = '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 { function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_'); return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
} }
@@ -30,7 +30,7 @@ export class JMAPClient {
get accountId(): string { get accountId(): string {
if (!this._accountId) { if (!this._accountId) {
throw new Error('Not authenticated — call connect() first'); throw new Error('Not authenticated - call connect() first');
} }
return this._accountId; return this._accountId;
} }
@@ -89,7 +89,7 @@ export class JMAPClient {
return this.session; 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. // should use loadAccount(accountId) driven by the account registry.
async restoreSession(): Promise<boolean> { async restoreSession(): Promise<boolean> {
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY); 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. // Expand the RFC 6570 level-1 template the JMAP server advertises.
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string { getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
if (!this.session?.downloadUrl) { 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 return this.session.downloadUrl
.replace('{accountId}', encodeURIComponent(this.accountId)) .replace('{accountId}', encodeURIComponent(this.accountId))
+1 -1
View File
@@ -55,7 +55,7 @@ export async function createPushSubscription(params: {
} }
/** /**
* Send the verification code back to the server — the call that flips the * Send the verification code back to the server - the call that flips the
* subscription from pending to active (RFC 8620 §7.2.2). * subscription from pending to active (RFC 8620 §7.2.2).
*/ */
export async function verifyPushSubscription( export async function verifyPushSubscription(
+43
View File
@@ -0,0 +1,43 @@
export const UPDATE_REPO = 'bulwarkmail/native';
export interface ReleaseAsset {
name: string;
browser_download_url: string;
size: number;
}
export interface LatestRelease {
tag: string;
name: string;
htmlUrl: string;
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`GitHub API ${res.status}`);
}
const json = (await res.json()) as {
tag_name: string;
name: string;
html_url: string;
published_at: string;
body: string;
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
return {
tag: json.tag_name,
name: json.name || json.tag_name,
htmlUrl: json.html_url,
publishedAt: json.published_at,
body: json.body || '',
apkAsset,
};
}
+1 -1
View File
@@ -521,7 +521,7 @@ const styles = StyleSheet.create({
accountName: { ...typography.bodyMedium, color: colors.text }, accountName: { ...typography.bodyMedium, color: colors.text },
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 }, 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: { accountMenu: {
marginHorizontal: spacing.sm, marginHorizontal: spacing.sm,
marginTop: spacing.xs, marginTop: spacing.xs,
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Download, X } from 'lucide-react-native';
import { useUpdatesStore } from '../stores/updates-store';
import { colors, spacing, radius, typography } from '../theme/tokens';
export function UpdateBanner(): React.ReactElement | null {
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const installing = useUpdatesStore((s) => s.installing);
const installLatest = useUpdatesStore((s) => s.installLatest);
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
if (!hasUpdate()) return null;
if (cachedLatest && dismissedTag === cachedLatest.tag) return null;
if (!cachedLatest?.apkAsset) return null;
return (
<View style={styles.banner}>
<Download size={16} color={colors.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.subtitle}>
v{cachedLatest.tag} is ready to install.
</Text>
</View>
<Pressable
style={styles.installButton}
onPress={() => void installLatest()}
disabled={installing}
>
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
</Pressable>
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
<X size={14} color={colors.primaryForeground} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.primary,
},
title: { ...typography.bodyMedium, color: colors.primaryForeground },
subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 },
installButton: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: 'rgba(255,255,255,0.2)',
},
installText: { ...typography.captionMedium, color: colors.primaryForeground },
dismiss: { padding: 4 },
});
+1 -1
View File
@@ -56,7 +56,7 @@ export function AgendaView({
day.setDate(start.getDate() + i); day.setDate(start.getDate() + i);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => { const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
// All-day events first within a day, then by start time, then title // 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. // packing week segments and keeps multi-event days predictable.
if (a.showWithoutTime !== b.showWithoutTime) { if (a.showWithoutTime !== b.showWithoutTime) {
return a.showWithoutTime ? -1 : 1; return a.showWithoutTime ? -1 : 1;
+2 -2
View File
@@ -84,7 +84,7 @@ export function WeekView({
? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs ? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs
: 0; : 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. // already promoted to the all-day strip above.
const layoutsByDay = React.useMemo(() => { const layoutsByDay = React.useMemo(() => {
return weekDays.map((day) => { return weekDays.map((day) => {
@@ -170,7 +170,7 @@ export function WeekView({
{weekDays.map((day) => ( {weekDays.map((day) => (
<View key={day.toISOString()} style={styles.allDayCol} /> <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"> <View style={styles.allDayOverlay} pointerEvents="box-none">
{allDaySegments.map((segment) => { {allDaySegments.map((segment) => {
const color = getEventColor(segment.event, calendars); const color = getEventColor(segment.event, calendars);
@@ -14,7 +14,7 @@ interface Session {
} }
const MOCK_SESSIONS: 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 }, { id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
]; ];
@@ -112,7 +112,7 @@ export function NotificationSettings() {
<View style={styles.container}> <View style={styles.container}>
<SettingsSection <SettingsSection
title="Push Notifications" 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.pushCard}>
<View style={styles.pushHeader}> <View style={styles.pushHeader}>
+124
View File
@@ -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 },
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { useVacationStore } from '../../stores/vacation-store';
function toJmapDate(input: string): string | null { function toJmapDate(input: string): string | null {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return null; 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 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); const dateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(trimmed);
if (dateOnly) return `${trimmed}T00:00:00`; if (dateOnly) return `${trimmed}T00:00:00`;
@@ -14,7 +14,7 @@ function ev(partial: Partial<CalendarEvent>): CalendarEvent {
} as CalendarEvent; } as CalendarEvent;
} }
describe('expandRecurringEvents — daily', () => { describe('expandRecurringEvents - daily', () => {
it('expands a daily rule with count', () => { it('expands a daily rule with count', () => {
const rule: RecurrenceRule = { frequency: 'daily', count: 3 }; const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
const result = expandRecurringEvents( 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', () => { 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 = { const rule: RecurrenceRule = {
frequency: 'weekly', frequency: 'weekly',
byDay: [{ day: 'mo' }, { day: 'we' }], byDay: [{ day: 'mo' }, { day: 'we' }],
@@ -74,7 +74,7 @@ describe('expandRecurringEvents — weekly', () => {
}); });
}); });
describe('expandRecurringEvents — monthly', () => { describe('expandRecurringEvents - monthly', () => {
it('expands monthly on the 6th', () => { it('expands monthly on the 6th', () => {
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 }; const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
const result = expandRecurringEvents( const result = expandRecurringEvents(
@@ -108,7 +108,7 @@ describe('expandRecurringEvents — monthly', () => {
}); });
}); });
describe('expandRecurringEvents — overrides', () => { describe('expandRecurringEvents - overrides', () => {
it('skips occurrences marked excluded', () => { it('skips occurrences marked excluded', () => {
const rule: RecurrenceRule = { frequency: 'daily', count: 3 }; const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
const event = ev({ 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', () => { it('returns non-recurring events unchanged', () => {
const single = ev({ id: 'one', recurrenceRules: undefined }); const single = ev({ id: 'one', recurrenceRules: undefined });
const result = expandRecurringEvents( 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', () => { it('returns occurrences only within the requested window', () => {
const rule: RecurrenceRule = { frequency: 'daily', count: 30 }; const rule: RecurrenceRule = { frequency: 'daily', count: 30 };
const result = expandRecurringEvents( const result = expandRecurringEvents(
+4 -4
View File
@@ -1,10 +1,10 @@
// Two avatar schemes, ported verbatim from the webmail: // 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 // the account's email. Used for the sidebar account block and account
// switcher. // 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. // name or email. Used for per-message avatars in the email list.
// //
// Both variants also define `getInitials` with slightly different rules, so we // 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 // logo from DuckDuckGo. The webmail goes through `/api/favicon?domain=...` as
// a caching proxy; native has no CORS constraint, so we hit DuckDuckGo direct. // 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 // server PSL). Covers the common cases so subdomains resolve to the right
// registrable domain (e.g. newsletter.example.co.uk → example.co.uk). // registrable domain (e.g. newsletter.example.co.uk → example.co.uk).
const MULTI_PART_TLDS = new Set([ 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', '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. // anything specific to the sender, so we skip it.
const PERSONAL_DOMAINS = new Set([ const PERSONAL_DOMAINS = new Set([
'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com',
+4 -4
View File
@@ -87,7 +87,7 @@ export function eventsOnDay(events: CalendarEvent[], day: Date): CalendarEvent[]
// O(days × events) with two parseISO calls per event per day. For ~200 events // 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. // that's ~16k parseISO calls per month render, redone on every swipe.
// `buildEventDayIndex` parses each event's start/end once, then walks only the // `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. // local-date key (yyyy-MM-dd) to the events on that day.
export type EventDayIndex = Map<string, CalendarEvent[]>; export type EventDayIndex = Map<string, CalendarEvent[]>;
@@ -104,7 +104,7 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
const start = getEventStartDate(event); const start = getEventStartDate(event);
const end = getEventDisplayEndDate(event); const end = getEventDisplayEndDate(event);
let day = startOfDay(start); 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 // include day while day < end. Safety bound guards against malformed
// multi-year events that would otherwise blow up the loop. // multi-year events that would otherwise blow up the loop.
let safety = 0; let safety = 0;
@@ -193,7 +193,7 @@ export function getTimedEventBoundsForDay(
// ─── Timed events that fill the day ────────────────────── // ─── Timed events that fill the day ──────────────────────
// Events shown in the timed grid that effectively span 00:00–24:00 on the // 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 // 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 { export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
const bounds = getTimedEventBoundsForDay(event, day); const bounds = getTimedEventBoundsForDay(event, day);
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440; 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 // 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 // the same `totalColumns`; across clusters, events get to use the full width
// they actually need. The previous global packing forced an event with no // 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 { export interface TimedEventLayout {
event: CalendarEvent; event: CalendarEvent;
column: number; column: number;
+4 -4
View File
@@ -6,7 +6,7 @@
// so inline <script>, inline event handlers, and external resources are // so inline <script>, inline event handlers, and external resources are
// blocked by the engine itself. This is the primary boundary. // blocked by the engine itself. This is the primary boundary.
// 2. `onShouldStartLoadWithRequest` refuses any navigation except the // 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 // 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 // rendered DOM is also clean. Regex stripping is imperfect; it is here
// as belt-and-suspenders, not the primary defense. // 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 // 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 // filter inversion trick for dark mode (unless the email has native dark
// support via @media (prefers-color-scheme: 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 // Strict CSP. Inline <script> and handlers from the email are stripped by
// `stripDangerousTags`, so script-src only needs to allow our own injected // `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). // 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 imgSrc = blockRemoteImages ? "img-src data: cid:" : "img-src data: cid: https: http:";
const csp = [ const csp = [
@@ -286,7 +286,7 @@ function blockRemoteImageSrcs(html: string): string {
// Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts). // Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts).
// Returns false when the HTML is an auto-generated minimal wrapper around plain // 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. // textBody in that case, since the server-side HTML often collapses newlines.
const MEANINGFUL_HTML_RE = 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; /<(?: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;
+21
View File
@@ -0,0 +1,21 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import type { ReleaseAsset } from '../api/updates';
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);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType: 'application/vnd.android.package-archive',
dialogTitle: 'Install update',
UTI: 'com.android.package-archive',
});
}
+1 -1
View File
@@ -29,7 +29,7 @@ interface BulwarkFcmNative {
} }
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a // 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. // resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> { export async function pushBackgroundTask(_data: unknown): Promise<void> {
try { try {
+5 -5
View File
@@ -18,11 +18,11 @@ const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1'; const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Hosted relay so users don't need to run their own Firebase project. The // 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. // Power users can override this from the settings screen.
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org'; 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. // outgoing mail state changes don't belong in a user-visible push.
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const; const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
@@ -37,7 +37,7 @@ function getNative(): BulwarkFcmNative | null {
} }
export interface PushSetupParams { export interface PushSetupParams {
// Optional — falls back to the hosted relay if omitted. // Optional - falls back to the hosted relay if omitted.
relayBaseUrl?: string; relayBaseUrl?: string;
accountLabel?: string; accountLabel?: string;
} }
@@ -213,7 +213,7 @@ export async function setupPushNotifications(
/** /**
* Tear down push on logout / disable. Removes the server-side JMAP * Tear down push on logout / disable. Removes the server-side JMAP
* subscription, tells the relay to drop the mapping, and clears the local * 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> { export async function teardownPushNotifications(): Promise<void> {
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY); const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
@@ -266,7 +266,7 @@ export interface NotificationTapPayload {
subject?: string; 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 // before JS was ready to handle it. Subsequent taps while running are delivered
// via addNotificationTapListener. // via addNotificationTapListener.
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> { export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
+17
View File
@@ -0,0 +1,17 @@
export function parseVersion(v: string): [number, number, number] {
const cleaned = v.replace(/^v/, '').split(/[-+]/)[0];
const parts = cleaned.split('.').map((n) => parseInt(n, 10));
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
}
export function compareVersions(a: string, b: string): number {
const [a1, a2, a3] = parseVersion(a);
const [b1, b2, b3] = parseVersion(b);
if (a1 !== b1) return a1 - b1;
if (a2 !== b2) return a2 - b2;
return a3 - b3;
}
export function isNewer(latest: string, current: string): boolean {
return compareVersions(latest, current) > 0;
}
+1 -1
View File
@@ -210,7 +210,7 @@ export default function CalendarScreen() {
const action = pendingAction; const action = pendingAction;
setPendingAction(null); setPendingAction(null);
if (!action) return; 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. // handling lives in the store's update/delete path against originalId.
// TODO: implement "this only" via recurrenceOverrides write, and // TODO: implement "this only" via recurrenceOverrides write, and
// "this and following" via excludedRecurrenceRules + new master. // "this and following" via excludedRecurrenceRules + new master.
+1 -1
View File
@@ -141,7 +141,7 @@ export default function ContactsScreen() {
const sections = React.useMemo<Section[]>(() => { const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible); 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) }]; return [{ title: '', data: sortContactsByDisplayName(visible) }];
}, [visible, groupByLetter]); }, [visible, groupByLetter]);
+6 -6
View File
@@ -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 <Pressable
onPress={onComposePress} onPress={onComposePress}
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]} style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
@@ -749,7 +749,7 @@ function TriToggle({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background }, 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: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -770,7 +770,7 @@ const styles = StyleSheet.create({
height: 28, height: 28,
}, },
// Search — matches web search bar styling // Search - matches web search bar styling
searchBar: { searchBar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -1026,7 +1026,7 @@ const styles = StyleSheet.create({
color: colors.primary, color: colors.primary,
}, },
// Email list — matches web email-list-item // Email list - matches web email-list-item
listContent: { paddingBottom: 100 }, listContent: { paddingBottom: 100 },
emailRow: { emailRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -1111,7 +1111,7 @@ const styles = StyleSheet.create({
tagText: { ...typography.small, fontWeight: '500' }, tagText: { ...typography.small, fontWeight: '500' },
separator: { height: 0 }, // borders are on rows now 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: { fab: {
position: 'absolute', position: 'absolute',
right: spacing.lg, // right-4 right: spacing.lg, // right-4
@@ -1119,7 +1119,7 @@ const styles = StyleSheet.create({
width: componentSizes.fab, // 56px (h-14) width: componentSizes.fab, // 56px (h-14)
height: componentSizes.fab, // 56px (w-14) height: componentSizes.fab, // 56px (w-14)
borderRadius: radius.full, // rounded-full (circle) borderRadius: radius.full, // rounded-full (circle)
backgroundColor: colors.text, // white — matches webmail mobile FAB backgroundColor: colors.text, // white - matches webmail mobile FAB
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
elevation: 6, elevation: 6,
+441 -13
View File
@@ -1,20 +1,37 @@
import React from 'react'; import React from 'react';
import { import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator, View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward, 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'; } from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import EmailBodyView from '../components/EmailBodyView'; import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar'; import SenderAvatar from '../components/SenderAvatar';
import { useEmailStore } from '../stores/email-store'; 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'; 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'>; type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
function formatHeaderDate(iso: string): string { function formatHeaderDate(iso: string): string {
@@ -43,16 +60,19 @@ function plainTextBody(email: Email): string {
export default function EmailThreadScreen({ route, navigation }: Props) { export default function EmailThreadScreen({ route, navigation }: Props) {
const { emailId, subject: subjectParam } = route.params; const { emailId, subject: subjectParam } = route.params;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail); const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
const markRead = useEmailStore((s) => s.markRead); const markRead = useEmailStore((s) => s.markRead);
const toggleStar = useEmailStore((s) => s.toggleStar);
const deleteEmail = useEmailStore((s) => s.deleteEmail); const deleteEmail = useEmailStore((s) => s.deleteEmail);
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const mailboxes = useEmailStore((s) => s.mailboxes); const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId); const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const [email, setEmail] = React.useState<Email | null>(null); const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -78,16 +98,46 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
}, [emailId, getEmailDetail, markRead]); }, [emailId, getEmailDetail, markRead]);
const starred = !!email?.keywords?.$flagged; 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 = () => { const onToggleStar = () => {
if (!email) return; if (!email) return;
void toggleStar(email.id, !starred); const next = { ...email.keywords };
setEmail({ if (starred) delete next.$flagged;
...email, else next.$flagged = true;
keywords: { updateLocalKeywords(next);
...email.keywords, void setEmailKeywords(email.id, next);
...(starred ? {} : { $flagged: true }), };
},
}); 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 = () => { const onDelete = () => {
@@ -98,6 +148,35 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
navigation.goBack(); 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') => { const navigateCompose = (mode: 'reply' | 'replyAll' | 'forward') => {
if (!email) return; if (!email) return;
const from = email.from?.[0]; const from = email.from?.[0];
@@ -119,6 +198,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const from = email?.from?.[0]; const from = email?.from?.[0];
const bottomBarHeight = 60 + Math.max(insets.bottom, 4); 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 ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
{/* Toolbar */} {/* Toolbar */}
@@ -132,6 +216,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
label="Delete" label="Delete"
onPress={onDelete} 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 <ToolbarButton
icon={ icon={
<Star <Star
@@ -146,6 +250,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ToolbarButton <ToolbarButton
icon={<MoreVertical size={18} color={colors.textSecondary} />} icon={<MoreVertical size={18} color={colors.textSecondary} />}
label="More" label="More"
onPress={() => setMoreMenuOpen(true)}
/> />
</View> </View>
</View> </View>
@@ -203,7 +308,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
) : null} ) : null}
<Text style={styles.senderRecipients} numberOfLines={1}> <Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text> <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> </Text>
</View> </View>
</View> </View>
@@ -262,10 +367,243 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</View> </View>
</> </>
) : null} ) : 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> </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({ function ToolbarButton({
icon, label, onPress, icon, label, onPress,
}: { icon: React.ReactNode; label: string; onPress?: () => void }) { }: { icon: React.ReactNode; label: string; onPress?: () => void }) {
@@ -324,7 +662,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 2, gap: 2,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
minHeight: 44, minHeight: 44,
}, },
@@ -487,4 +825,94 @@ const styles = StyleSheet.create({
bottomBarLabelDisabled: { bottomBarLabelDisabled: {
color: colors.textMuted, 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,
},
}); });
+14 -11
View File
@@ -5,7 +5,7 @@ import {
ArrowLeft, LogOut, Settings, ChevronRight, ArrowLeft, LogOut, Settings, ChevronRight,
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar, Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench, Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
type LucideIcon, type LucideIcon,
} from 'lucide-react-native'; } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; 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 { VacationSettings } from '../components/settings/VacationSettings';
import { FolderSettings } from '../components/settings/FolderSettings'; import { FolderSettings } from '../components/settings/FolderSettings';
import { AdvancedSettings } from '../components/settings/AdvancedSettings'; import { AdvancedSettings } from '../components/settings/AdvancedSettings';
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
type Tab = type Tab =
| 'appearance' | 'email' | 'notifications' | 'appearance' | 'email' | 'notifications'
| 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords' | 'filters' | 'templates' | 'folders' | 'keywords'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps' | 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
| 'themes' | 'plugins' | 'advanced'; | 'themes' | 'plugins' | 'updates' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; 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: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, 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: '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 }, { 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, identities: IdentitySettings,
vacation: VacationSettings, vacation: VacationSettings,
folders: FolderSettings, folders: FolderSettings,
updates: UpdatesSettings,
advanced: AdvancedSettings, advanced: AdvancedSettings,
}; };
@@ -128,7 +131,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <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}> <View style={styles.header}>
<Pressable <Pressable
onPress={onBack} onPress={onBack}
@@ -140,7 +143,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
<Text style={styles.headerTitle}>Settings</Text> <Text style={styles.headerTitle}>Settings</Text>
</View> </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}> <ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
<View style={styles.tabList}> <View style={styles.tabList}>
{groupedTabs.map((group, groupIndex) => ( {groupedTabs.map((group, groupIndex) => (
@@ -195,7 +198,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
))} ))}
</View> </View>
{/* Logout — matches webmail: border-t, destructive text, icon + label */} {/* Logout - matches webmail: border-t, destructive text, icon + label */}
<View style={styles.logoutSection}> <View style={styles.logoutSection}>
<Pressable <Pressable
onPress={onLogout} onPress={onLogout}
@@ -216,7 +219,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.background, 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: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -256,7 +259,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm, 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: { groupDivider: {
height: 1, height: 1,
backgroundColor: colors.border, backgroundColor: colors.border,
@@ -264,7 +267,7 @@ const styles = StyleSheet.create({
marginVertical: spacing.sm, marginVertical: spacing.sm,
}, },
// Group header — webmail: px-5 pt-3 pb-1.5 // Group header - webmail: px-5 pt-3 pb-1.5
groupHeader: { groupHeader: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: spacing.md, paddingTop: spacing.md,
@@ -278,7 +281,7 @@ const styles = StyleSheet.create({
color: colors.mutedForeground, 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: { tabItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -300,7 +303,7 @@ const styles = StyleSheet.create({
color: colors.text, 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: { experimentalBadge: {
backgroundColor: 'rgba(202, 138, 4, 0.15)', backgroundColor: 'rgba(202, 138, 4, 0.15)',
borderRadius: radius.full, borderRadius: radius.full,
@@ -331,7 +334,7 @@ const styles = StyleSheet.create({
color: colors.mutedForeground, 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: { logoutSection: {
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: colors.border, borderTopColor: colors.border,
+3 -3
View File
@@ -104,7 +104,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (serverUrl, username, password, opts) => { login: async (serverUrl, username, password, opts) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { 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. // new account starts with a clean slate.
if (opts?.addAccount && get().isAuthenticated) { if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset(); jmapClient.reset();
@@ -221,7 +221,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const ok = await jmapClient.loadAccount(accountId); const ok = await jmapClient.loadAccount(accountId);
if (!ok) { if (!ok) {
// Credentials missing — evict stale entry and surface error // Credentials missing - evict stale entry and surface error
accountStore.removeAccount(accountId); accountStore.removeAccount(accountId);
set({ isLoading: false, error: 'Session expired for this account' }); set({ isLoading: false, error: 'Session expired for this account' });
return; return;
@@ -250,7 +250,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try { try {
// Wait for persisted caches to finish hydrating from AsyncStorage. // Wait for persisted caches to finish hydrating from AsyncStorage.
// Otherwise we read empty defaults and bounce the user back to the // 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 // yet when refetchFeatureStores() checks currentMailboxId / loadedRange
// at the end of this function. // at the end of this function.
await Promise.all([ await Promise.all([
+1 -1
View File
@@ -122,7 +122,7 @@ export const useCalendarStore = create<CalendarState>()(
const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before }; const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before };
const ids = calendars.map((c) => c.id); const ids = calendars.map((c) => c.id);
if (ids.length === 0) { 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(); await get().fetchCalendars();
} }
const calendarIdsAfter = get().calendars.map((c) => c.id); const calendarIdsAfter = get().calendars.map((c) => c.id);
+2 -2
View File
@@ -114,7 +114,7 @@ export const useEmailStore = create<EmailState>()(
}, },
selectMailbox: async (mailboxId) => { selectMailbox: async (mailboxId) => {
// Reset search/filters when switching mailbox — matches webmail behavior. // Reset search/filters when switching mailbox - matches webmail behavior.
set({ set({
currentMailboxId: mailboxId, currentMailboxId: mailboxId,
loading: true, loading: true,
@@ -157,7 +157,7 @@ export const useEmailStore = create<EmailState>()(
refreshEmails: async () => { refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters } = get(); const { currentMailboxId, searchQuery, filters } = get();
if (!currentMailboxId) return; 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. // the new list is ready so pull-to-refresh doesn't flash an empty list.
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
+132
View File
@@ -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
View File
@@ -1,4 +1,4 @@
// Qui Dark Theme — exact values from Bulwark Webmail's builtin-themes.ts // Qui Dark Theme - exact values from Bulwark Webmail's builtin-themes.ts
export const colors = { export const colors = {
// Primary brand (Qui theme) // Primary brand (Qui theme)
primary: '#3b82f6', primary: '#3b82f6',
@@ -69,7 +69,7 @@ export const colors = {
orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark 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 // dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400
tags: { tags: {
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' }, 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' }, gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
}, },
// Navigation — matches web nav-rail // Navigation - matches web nav-rail
navActive: '#3b82f6', navActive: '#3b82f6',
navInactive: '#71717a', // --color-muted-foreground navInactive: '#71717a', // --color-muted-foreground
navBadge: '#ef4444', // red-500 navBadge: '#ef4444', // red-500
@@ -145,18 +145,18 @@ export const typography = {
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg 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 }, body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 }, bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 },
bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 }, bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 },
bodyBold: { fontSize: 14, fontWeight: '700' 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 }, base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 }, baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 },
// Caption = text-xs (12px) // Caption = text-xs (12px)
caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 }, caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 },
captionMedium: { fontSize: 12, fontWeight: '500' 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 }, small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 }, tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
} as const; } as const;