mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
chore: update readme
This commit is contained in:
@@ -112,7 +112,7 @@ jobs:
|
||||
if [ -n "$TAG" ]; then
|
||||
echo "Will attach APK to release: $TAG"
|
||||
else
|
||||
echo "No release tag resolved — APK is in workflow artifacts only."
|
||||
echo "No release tag resolved - APK is in workflow artifacts only."
|
||||
fi
|
||||
|
||||
- name: Attach APK to release
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background — the FirebaseMessagingService also posts the system
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = addMessageListener((payload) => {
|
||||
|
||||
+60
-53
@@ -1,4 +1,4 @@
|
||||
# React Native Calendar — Parity Plan
|
||||
# React Native Calendar - Parity Plan
|
||||
|
||||
Bring `repos/react-native/` calendar to parity with the webmail calendar at
|
||||
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus
|
||||
@@ -7,52 +7,55 @@ essentials and explicit deferrals for things that don't fit a phone UX.
|
||||
|
||||
## Current state
|
||||
|
||||
- `src/screens/CalendarScreenNew.tsx` — month grid + agenda-for-selected-day,
|
||||
- `src/screens/CalendarScreenNew.tsx` - month grid + agenda-for-selected-day,
|
||||
all rendering `MOCK_EVENTS`. Doesn't touch the store.
|
||||
- `src/stores/calendar-store.ts` — CRUD wiring exists (`fetchCalendars`,
|
||||
- `src/stores/calendar-store.ts` - CRUD wiring exists (`fetchCalendars`,
|
||||
`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
|
||||
`handleStateChange`, no per-calendar visibility, no range tracking.
|
||||
- `src/api/calendar.ts` — JMAP methods wired (`Calendar/get`,
|
||||
- `src/api/calendar.ts` - JMAP methods wired (`Calendar/get`,
|
||||
`CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`).
|
||||
- `src/api/types.ts` — `CalendarEvent` and `Calendar` types match webmail.
|
||||
- `src/api/types.ts` - `CalendarEvent` and `Calendar` types match webmail.
|
||||
- No event modal, no detail view, no week/day view, no sidebar, no recurrence.
|
||||
|
||||
## In scope
|
||||
|
||||
### Phase 1 — Data layer (foundation for everything else)
|
||||
### Phase 1 - Data layer (foundation for everything else)
|
||||
|
||||
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
|
||||
|
||||
**`src/stores/calendar-store.ts`** — expand to match webmail's store semantics:
|
||||
- `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
|
||||
`CalendarSidebar` toggle state).
|
||||
- `loadedRange: { after: string; before: string } | null` — track what's fetched
|
||||
- `loadedRange: { after: string; before: string } | null` - track what's fetched
|
||||
so view changes don't re-fetch unnecessarily.
|
||||
- `ensureRange(after, before)` — fetch only if the requested window isn't
|
||||
- `ensureRange(after, before)` - fetch only if the requested window isn't
|
||||
covered by `loadedRange`; expand the loaded range in place instead of
|
||||
replacing events wholesale, so switching Month → Week doesn't drop data.
|
||||
- `handleStateChange(change)` — subscribe to `Calendar` and `CalendarEvent`
|
||||
- `handleStateChange(change)` - subscribe to `Calendar` and `CalendarEvent`
|
||||
typeState keys; on push, refetch calendars and re-query the current window.
|
||||
Already referenced from `App.tsx` push handler — this finally implements it.
|
||||
Already referenced from `App.tsx` push handler - this finally implements it.
|
||||
- `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
|
||||
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
|
||||
|
||||
**`src/lib/calendar-utils.ts`** (new) — port from webmail's `lib/calendar-utils.ts`:
|
||||
- `parseLocalDateTime(iso)` — JSCalendar start strings are floating local time
|
||||
**`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`:
|
||||
|
||||
- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time
|
||||
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
|
||||
- `parseDuration(iso8601)` — `PT1H30M` → milliseconds.
|
||||
- `eventTimeRange(event)` — returns `{ start: Date, end: Date, allDay: boolean }`
|
||||
- `parseDuration(iso8601)` - `PT1H30M` → milliseconds.
|
||||
- `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }`
|
||||
handling `showWithoutTime`.
|
||||
- `eventsOnDay(events, day)` — day overlap check (not just start-matches-day).
|
||||
- `getCalendarColor(cal)` — fallback to a deterministic palette when `color`
|
||||
- `eventsOnDay(events, day)` - day overlap check (not just start-matches-day).
|
||||
- `getCalendarColor(cal)` - fallback to a deterministic palette when `color`
|
||||
is missing, matching `theme/tokens.ts` `colors.calendar.*`.
|
||||
|
||||
**`src/lib/recurrence-expansion.ts`** (new) — minimal port of webmail's RRULE
|
||||
**`src/lib/recurrence-expansion.ts`** (new) - minimal port of webmail's RRULE
|
||||
expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`.
|
||||
Mobile port: same surface, same tests. This is the hard part — allocate ~400
|
||||
Mobile port: same surface, same tests. This is the hard part - allocate ~400
|
||||
lines. Drives correct rendering of recurring events across the visible window.
|
||||
|
||||
### Phase 2 — Views
|
||||
### Phase 2 - Views
|
||||
|
||||
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
|
||||
|
||||
**`src/screens/CalendarScreenNew.tsx`** — replace mocks, gain view switching:
|
||||
- On mount: `fetchCalendars()`, then `ensureRange` for visible window.
|
||||
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone).
|
||||
- Header shows month/week label; prev/next shifts by the current view's step.
|
||||
@@ -60,12 +63,13 @@ lines. Drives correct rendering of recurring events across the visible window.
|
||||
- FAB opens create modal with `start = selectedDate at next half hour`.
|
||||
|
||||
**`src/components/calendar/MonthView.tsx`** (extract from current screen,
|
||||
~180 lines) — real events, up to 3 colored dots per day, "+N" indicator when
|
||||
~180 lines) - real events, up to 3 colored dots per day, "+N" indicator when
|
||||
more. Tap day → selects + scrolls agenda below. Long-press day → opens create
|
||||
modal with that date.
|
||||
|
||||
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) — port of
|
||||
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) - port of
|
||||
`calendar-week-view.tsx` (511 lines) trimmed for mobile:
|
||||
|
||||
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
|
||||
- All-day strip at top.
|
||||
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among
|
||||
@@ -73,35 +77,37 @@ modal with that date.
|
||||
- Tap event → detail modal. Long-press empty slot → create modal pre-filled.
|
||||
- Horizontal swipe → prev/next week (react-native-gesture-handler already
|
||||
available via react-navigation).
|
||||
- *Deferred*: drag-to-reschedule, drag-to-resize. Phone targets are too small
|
||||
- _Deferred_: drag-to-reschedule, drag-to-resize. Phone targets are too small
|
||||
for it to be better than tap-edit.
|
||||
|
||||
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) — port of
|
||||
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) - port of
|
||||
`calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
|
||||
days forward. Taps open detail modal.
|
||||
|
||||
### Phase 3 — Event interaction
|
||||
### Phase 3 - Event interaction
|
||||
|
||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) — bottom
|
||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) - bottom
|
||||
sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines
|
||||
on web — phone version drops the hover card chrome and the inline-edit fields):
|
||||
on web - phone version drops the hover card chrome and the inline-edit fields):
|
||||
|
||||
- Header: calendar color bar, title, date/time range (or "All day"), calendar name.
|
||||
- Sections: location, description (linkified), participants with RSVP status,
|
||||
recurrence summary (e.g. "Weekly on Mon, Wed — until 2026-06-01"), reminders.
|
||||
recurrence summary (e.g. "Weekly on Mon, Wed - until 2026-06-01"), reminders.
|
||||
- Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
|
||||
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
|
||||
only" / "This and following" / "All events").
|
||||
|
||||
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) — full-screen
|
||||
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) - full-screen
|
||||
modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
|
||||
|
||||
- Title, calendar picker (color swatches), all-day toggle.
|
||||
- Start/end date+time pickers (`@react-native-community/datetimepicker` — add dep).
|
||||
- Start/end date+time pickers (`@react-native-community/datetimepicker` - add dep).
|
||||
- Location, description (multiline).
|
||||
- Participant input with autocomplete from contacts store (port of
|
||||
`participant-input.tsx`, ~180 lines).
|
||||
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
|
||||
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.)
|
||||
are deferred — covers the 95% case.
|
||||
are deferred - covers the 95% case.
|
||||
- Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
|
||||
`Alert.trigger.offset`.
|
||||
- Timezone: default to device TZ; optional override picker hidden behind
|
||||
@@ -109,25 +115,26 @@ modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed)
|
||||
- Save path handles "this instance only" override vs master update for
|
||||
recurring events (write to `recurrenceOverrides[recurrenceId]`).
|
||||
|
||||
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) —
|
||||
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) -
|
||||
direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
|
||||
edit/delete of a recurring instance.
|
||||
|
||||
### Phase 4 — Multi-calendar
|
||||
### Phase 4 - Multi-calendar
|
||||
|
||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) —
|
||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) -
|
||||
port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
|
||||
(reuses `SidebarDrawer.tsx`):
|
||||
|
||||
- List of calendars with color swatch + visibility toggle + name.
|
||||
- "My calendars" vs "Subscribed" sections if `myRights` differs.
|
||||
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
|
||||
- Footer: "Add calendar" (deferred to Phase 6).
|
||||
|
||||
**`src/theme/tokens.ts`** — audit `colors.calendar.*` palette against webmail's
|
||||
**`src/theme/tokens.ts`** - audit `colors.calendar.*` palette against webmail's
|
||||
calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
|
||||
add any missing shades.
|
||||
|
||||
### Phase 5 — Polish
|
||||
### Phase 5 - Polish
|
||||
|
||||
- Pull-to-refresh on each view → forces re-fetch of current window.
|
||||
- Empty states per view (no calendars yet / no events this month).
|
||||
@@ -141,28 +148,28 @@ add any missing shades.
|
||||
These are in the webmail but don't make sense for a first mobile pass:
|
||||
|
||||
- **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines,
|
||||
`ical-subscription-modal.tsx` 220 lines) — file picker + URL subscription
|
||||
`ical-subscription-modal.tsx` 220 lines) - file picker + URL subscription
|
||||
with auth flows. Better as a dedicated settings flow later.
|
||||
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) —
|
||||
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) -
|
||||
JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
|
||||
screen, not bolted onto calendar.
|
||||
- **Drag to reschedule / resize** — mobile tap targets are too small for the
|
||||
- **Drag to reschedule / resize** - mobile tap targets are too small for the
|
||||
fine-grained control the web drag gives; tap-edit is the mobile idiom.
|
||||
- **Quick event input** (`quick-event-input.tsx`) — NLP parser ("Lunch tomorrow
|
||||
- **Quick event input** (`quick-event-input.tsx`) - NLP parser ("Lunch tomorrow
|
||||
at noon"). Neat on desktop, marginal on phone where typing is already slow.
|
||||
- **Event context menu** (`event-context-menu.tsx`) — right-click is desktop-
|
||||
- **Event context menu** (`event-context-menu.tsx`) - right-click is desktop-
|
||||
only. Actions move into the detail sheet's action row.
|
||||
- **Mini-calendar** (`mini-calendar.tsx`) — web uses it as a sidebar date
|
||||
- **Mini-calendar** (`mini-calendar.tsx`) - web uses it as a sidebar date
|
||||
picker. Mobile already has the main month view; redundant.
|
||||
- **Birthday calendar auto-derived events** — lower priority; leave the
|
||||
- **Birthday calendar auto-derived events** - lower priority; leave the
|
||||
contacts-store integration for a follow-up.
|
||||
|
||||
## Dependencies to add
|
||||
|
||||
- `@react-native-community/datetimepicker` — native date/time pickers.
|
||||
- `@react-native-community/datetimepicker` - native date/time pickers.
|
||||
- `react-native-reanimated` is already in (via react-navigation); reuse for
|
||||
bottom-sheet animations on `EventDetailSheet`.
|
||||
- No new calendar library — reimplement RRULE expansion in ~400 lines rather
|
||||
- No new calendar library - reimplement RRULE expansion in ~400 lines rather
|
||||
than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
|
||||
|
||||
## File map (new / modified)
|
||||
@@ -207,14 +214,14 @@ repos/react-native/src/
|
||||
|
||||
## Build order (suggested)
|
||||
|
||||
1. Phase 1 in full — nothing else works without real data.
|
||||
2. MonthView + EventDetailSheet (read path) — first thing user sees.
|
||||
3. EventModal create path — unblocks manual testing of write path.
|
||||
4. AgendaView + CalendarSidebarDrawer — pulls the rest of month together.
|
||||
5. WeekView — biggest UI, easiest to defer if time runs short.
|
||||
6. EventModal edit + recurrence scope — finishes the edit surface.
|
||||
1. Phase 1 in full - nothing else works without real data.
|
||||
2. MonthView + EventDetailSheet (read path) - first thing user sees.
|
||||
3. EventModal create path - unblocks manual testing of write path.
|
||||
4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together.
|
||||
5. WeekView - biggest UI, easiest to defer if time runs short.
|
||||
6. EventModal edit + recurrence scope - finishes the edit surface.
|
||||
7. Phase 5 polish.
|
||||
|
||||
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util
|
||||
changes. Each phase is ship-safe on its own — the app stays functional even if
|
||||
changes. Each phase is ship-safe on its own - the app stays functional even if
|
||||
we stop at Phase 2.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
|
||||
# Bulwark Mobile
|
||||
|
||||
> **Beta - work in progress.** Many features are unfinished or rough. Expect bugs, missing functionality, and breaking changes between releases. Do not rely on this for primary email yet.
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
- Multiple accounts
|
||||
- Email list, threads, compose
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via FCM relay
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS build is untested
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases)
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
@@ -166,7 +166,7 @@ dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
// Firebase Cloud Messaging — delivers pushes via Google Play services.
|
||||
// Firebase Cloud Messaging - delivers pushes via Google Play services.
|
||||
// Requires android/app/google-services.json at build time.
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
@@ -26,7 +26,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
val data = message.data
|
||||
|
||||
// Hand off to JS via a headless task only when the app isn't already
|
||||
// running in the foreground — HeadlessJsTaskContext throws if started
|
||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||
// while foreground. When the app is open, the main JS instance already
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { generateAccountId } from '../lib/account-utils';
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only — no "@" or "/".
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
|
||||
function credentialsKey(accountId: string): string {
|
||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class JMAPClient {
|
||||
|
||||
get accountId(): string {
|
||||
if (!this._accountId) {
|
||||
throw new Error('Not authenticated — call connect() first');
|
||||
throw new Error('Not authenticated - call connect() first');
|
||||
}
|
||||
return this._accountId;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export class JMAPClient {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
// Legacy single-slot restore — kept for backward-compat tests. New code
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||
@@ -294,7 +294,7 @@ export class JMAPClient {
|
||||
// Expand the RFC 6570 level-1 template the JMAP server advertises.
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
if (!this.session?.downloadUrl) {
|
||||
throw new Error('Download URL not available — not connected');
|
||||
throw new Error('Download URL not available - not connected');
|
||||
}
|
||||
return this.session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ export async function createPushSubscription(params: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification code back to the server — the call that flips the
|
||||
* Send the verification code back to the server - the call that flips the
|
||||
* subscription from pending to active (RFC 8620 §7.2.2).
|
||||
*/
|
||||
export async function verifyPushSubscription(
|
||||
|
||||
@@ -521,7 +521,7 @@ const styles = StyleSheet.create({
|
||||
accountName: { ...typography.bodyMedium, color: colors.text },
|
||||
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
||||
|
||||
// Account menu — floating popover card under the header
|
||||
// Account menu - floating popover card under the header
|
||||
accountMenu: {
|
||||
marginHorizontal: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
|
||||
@@ -56,7 +56,7 @@ export function AgendaView({
|
||||
day.setDate(start.getDate() + i);
|
||||
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
|
||||
// All-day events first within a day, then by start time, then title
|
||||
// (stable tie-break) — mirrors the comparator the webmail uses for
|
||||
// (stable tie-break) - mirrors the comparator the webmail uses for
|
||||
// packing week segments and keeps multi-event days predictable.
|
||||
if (a.showWithoutTime !== b.showWithoutTime) {
|
||||
return a.showWithoutTime ? -1 : 1;
|
||||
|
||||
@@ -84,7 +84,7 @@ export function WeekView({
|
||||
? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs
|
||||
: 0;
|
||||
|
||||
// Timed grid excludes events that fill the full day on that day — they're
|
||||
// Timed grid excludes events that fill the full day on that day - they're
|
||||
// already promoted to the all-day strip above.
|
||||
const layoutsByDay = React.useMemo(() => {
|
||||
return weekDays.map((day) => {
|
||||
@@ -170,7 +170,7 @@ export function WeekView({
|
||||
{weekDays.map((day) => (
|
||||
<View key={day.toISOString()} style={styles.allDayCol} />
|
||||
))}
|
||||
{/* Segments overlay — multi-day events span their date range */}
|
||||
{/* Segments overlay - multi-day events span their date range */}
|
||||
<View style={styles.allDayOverlay} pointerEvents="box-none">
|
||||
{allDaySegments.map((segment) => {
|
||||
const color = getEventColor(segment.event, calendars);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Session {
|
||||
}
|
||||
|
||||
const MOCK_SESSIONS: Session[] = [
|
||||
{ id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export function NotificationSettings() {
|
||||
<View style={styles.container}>
|
||||
<SettingsSection
|
||||
title="Push Notifications"
|
||||
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing — never mail content."
|
||||
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing - never mail content."
|
||||
>
|
||||
<View style={styles.pushCard}>
|
||||
<View style={styles.pushHeader}>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function UpdatesSettings() {
|
||||
label="Latest version"
|
||||
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
|
||||
>
|
||||
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '—'}</Text>
|
||||
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '-'}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useVacationStore } from '../../stores/vacation-store';
|
||||
function toJmapDate(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" — normalize to "YYYY-MM-DDTHH:MM:SS".
|
||||
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" - normalize to "YYYY-MM-DDTHH:MM:SS".
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
|
||||
const dateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(trimmed);
|
||||
if (dateOnly) return `${trimmed}T00:00:00`;
|
||||
|
||||
@@ -14,7 +14,7 @@ function ev(partial: Partial<CalendarEvent>): CalendarEvent {
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
describe('expandRecurringEvents — daily', () => {
|
||||
describe('expandRecurringEvents - daily', () => {
|
||||
it('expands a daily rule with count', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
|
||||
const result = expandRecurringEvents(
|
||||
@@ -52,9 +52,9 @@ describe('expandRecurringEvents — daily', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — weekly', () => {
|
||||
describe('expandRecurringEvents - weekly', () => {
|
||||
it('expands a weekly rule on specific byDay values', () => {
|
||||
// April 6, 2026 is a Monday — expand Mon/Wed for two weeks.
|
||||
// April 6, 2026 is a Monday - expand Mon/Wed for two weeks.
|
||||
const rule: RecurrenceRule = {
|
||||
frequency: 'weekly',
|
||||
byDay: [{ day: 'mo' }, { day: 'we' }],
|
||||
@@ -74,7 +74,7 @@ describe('expandRecurringEvents — weekly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — monthly', () => {
|
||||
describe('expandRecurringEvents - monthly', () => {
|
||||
it('expands monthly on the 6th', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
|
||||
const result = expandRecurringEvents(
|
||||
@@ -108,7 +108,7 @@ describe('expandRecurringEvents — monthly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — overrides', () => {
|
||||
describe('expandRecurringEvents - overrides', () => {
|
||||
it('skips occurrences marked excluded', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
|
||||
const event = ev({
|
||||
@@ -146,7 +146,7 @@ describe('expandRecurringEvents — overrides', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — non-recurring passthrough', () => {
|
||||
describe('expandRecurringEvents - non-recurring passthrough', () => {
|
||||
it('returns non-recurring events unchanged', () => {
|
||||
const single = ev({ id: 'one', recurrenceRules: undefined });
|
||||
const result = expandRecurringEvents(
|
||||
@@ -159,7 +159,7 @@ describe('expandRecurringEvents — non-recurring passthrough', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — range filtering', () => {
|
||||
describe('expandRecurringEvents - range filtering', () => {
|
||||
it('returns occurrences only within the requested window', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 30 };
|
||||
const result = expandRecurringEvents(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Two avatar schemes, ported verbatim from the webmail:
|
||||
//
|
||||
// 1. Account avatar (account-utils.ts) — 12-color fixed palette, hashed from
|
||||
// 1. Account avatar (account-utils.ts) - 12-color fixed palette, hashed from
|
||||
// the account's email. Used for the sidebar account block and account
|
||||
// switcher.
|
||||
//
|
||||
// 2. Email-row avatar (components/ui/avatar.tsx) — HSL hue from a hash of
|
||||
// 2. Email-row avatar (components/ui/avatar.tsx) - HSL hue from a hash of
|
||||
// name or email. Used for per-message avatars in the email list.
|
||||
//
|
||||
// Both variants also define `getInitials` with slightly different rules, so we
|
||||
@@ -68,7 +68,7 @@ export function getEmailInitials(name: string, email?: string): string {
|
||||
// logo from DuckDuckGo. The webmail goes through `/api/favicon?domain=...` as
|
||||
// a caching proxy; native has no CORS constraint, so we hit DuckDuckGo direct.
|
||||
|
||||
// Multi-part TLDs (trimmed to the webmail client-side list — not the giant
|
||||
// Multi-part TLDs (trimmed to the webmail client-side list - not the giant
|
||||
// server PSL). Covers the common cases so subdomains resolve to the right
|
||||
// registrable domain (e.g. newsletter.example.co.uk → example.co.uk).
|
||||
const MULTI_PART_TLDS = new Set([
|
||||
@@ -100,7 +100,7 @@ const MULTI_PART_TLDS = new Set([
|
||||
'co.ke', 'or.ke', 'ac.ke', 'go.ke', 'ne.ke',
|
||||
]);
|
||||
|
||||
// Personal mail providers — the favicon here is the mail provider's logo, not
|
||||
// Personal mail providers - the favicon here is the mail provider's logo, not
|
||||
// anything specific to the sender, so we skip it.
|
||||
const PERSONAL_DOMAINS = new Set([
|
||||
'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com',
|
||||
|
||||
@@ -87,7 +87,7 @@ export function eventsOnDay(events: CalendarEvent[], day: Date): CalendarEvent[]
|
||||
// O(days × events) with two parseISO calls per event per day. For ~200 events
|
||||
// that's ~16k parseISO calls per month render, redone on every swipe.
|
||||
// `buildEventDayIndex` parses each event's start/end once, then walks only the
|
||||
// days that event actually touches — typically 1 day. Result is a map from
|
||||
// days that event actually touches - typically 1 day. Result is a map from
|
||||
// local-date key (yyyy-MM-dd) to the events on that day.
|
||||
export type EventDayIndex = Map<string, CalendarEvent[]>;
|
||||
|
||||
@@ -104,7 +104,7 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
|
||||
const start = getEventStartDate(event);
|
||||
const end = getEventDisplayEndDate(event);
|
||||
let day = startOfDay(start);
|
||||
// Cap at 366 days: matches the original semantics (end > dayStart) — we
|
||||
// Cap at 366 days: matches the original semantics (end > dayStart) - we
|
||||
// include day while day < end. Safety bound guards against malformed
|
||||
// multi-year events that would otherwise blow up the loop.
|
||||
let safety = 0;
|
||||
@@ -193,7 +193,7 @@ export function getTimedEventBoundsForDay(
|
||||
// ─── Timed events that fill the day ──────────────────────
|
||||
// Events shown in the timed grid that effectively span 00:00–24:00 on the
|
||||
// given day get promoted to the all-day strip in the week/day views (matches
|
||||
// webmail behavior — keeps the timed grid usable when an event covers the day).
|
||||
// webmail behavior - keeps the timed grid usable when an event covers the day).
|
||||
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
|
||||
const bounds = getTimedEventBoundsForDay(event, day);
|
||||
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
|
||||
@@ -315,7 +315,7 @@ export function buildTimedFullDayWeekSegments(
|
||||
// begins after every prior event has ended. Within a cluster every event gets
|
||||
// the same `totalColumns`; across clusters, events get to use the full width
|
||||
// they actually need. The previous global packing forced an event with no
|
||||
// neighbors at 17:00 to share columns with a 9am cluster — visible squish.
|
||||
// neighbors at 17:00 to share columns with a 9am cluster - visible squish.
|
||||
export interface TimedEventLayout {
|
||||
event: CalendarEvent;
|
||||
column: number;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// so inline <script>, inline event handlers, and external resources are
|
||||
// blocked by the engine itself. This is the primary boundary.
|
||||
// 2. `onShouldStartLoadWithRequest` refuses any navigation except the
|
||||
// initial about:blank doc — links are opened in the OS browser.
|
||||
// initial about:blank doc - links are opened in the OS browser.
|
||||
// 3. This module pre-strips known-dangerous tags and on* attributes so the
|
||||
// rendered DOM is also clean. Regex stripping is imperfect; it is here
|
||||
// as belt-and-suspenders, not the primary defense.
|
||||
@@ -82,7 +82,7 @@ export function plainTextToSafeHtml(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Base styles for HTML emails — mirrors the webmail iframe body 1:1.
|
||||
// Base styles for HTML emails - mirrors the webmail iframe body 1:1.
|
||||
// Emails are authored for light mode; we render them true-to-life and apply a
|
||||
// filter inversion trick for dark mode (unless the email has native dark
|
||||
// support via @media (prefers-color-scheme: dark)).
|
||||
@@ -171,7 +171,7 @@ export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): str
|
||||
|
||||
// Strict CSP. Inline <script> and handlers from the email are stripped by
|
||||
// `stripDangerousTags`, so script-src only needs to allow our own injected
|
||||
// measurement bridge — 'unsafe-inline' is the minimum that lets RN WebView's
|
||||
// measurement bridge - 'unsafe-inline' is the minimum that lets RN WebView's
|
||||
// injected script run on Android (where page CSP can apply to evaluateJs).
|
||||
const imgSrc = blockRemoteImages ? "img-src data: cid:" : "img-src data: cid: https: http:";
|
||||
const csp = [
|
||||
@@ -286,7 +286,7 @@ function blockRemoteImageSrcs(html: string): string {
|
||||
|
||||
// Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts).
|
||||
// Returns false when the HTML is an auto-generated minimal wrapper around plain
|
||||
// text (no <br>, no links, no rich tags) — the caller should fall back to the
|
||||
// text (no <br>, no links, no rich tags) - the caller should fall back to the
|
||||
// textBody in that case, since the server-side HTML often collapses newlines.
|
||||
const MEANINGFUL_HTML_RE =
|
||||
/<(?:table|img|style|b|strong|i|em|u|font|h[1-6]|ul|ol|blockquote|br)\b|<a\b[^>]*\bhref=|<(?:div|span|p)\b[^>]*\bstyle=/i;
|
||||
|
||||
@@ -29,7 +29,7 @@ interface BulwarkFcmNative {
|
||||
}
|
||||
|
||||
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
|
||||
// short-lived headless JS runtime — keep it fast, catch all errors, and always
|
||||
// short-lived headless JS runtime - keep it fast, catch all errors, and always
|
||||
// resolve so the native service can release its wake lock.
|
||||
export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -18,11 +18,11 @@ const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
|
||||
// Hosted relay so users don't need to run their own Firebase project. The
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes — no mail content.
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
|
||||
// Power users can override this from the settings screen.
|
||||
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
|
||||
|
||||
// Types the mobile app wants StateChange pings for. Submission is excluded —
|
||||
// Types the mobile app wants StateChange pings for. Submission is excluded -
|
||||
// outgoing mail state changes don't belong in a user-visible push.
|
||||
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
|
||||
|
||||
@@ -37,7 +37,7 @@ function getNative(): BulwarkFcmNative | null {
|
||||
}
|
||||
|
||||
export interface PushSetupParams {
|
||||
// Optional — falls back to the hosted relay if omitted.
|
||||
// Optional - falls back to the hosted relay if omitted.
|
||||
relayBaseUrl?: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export async function setupPushNotifications(
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
* FCM token. Never throws — callers treat teardown as best effort.
|
||||
* FCM token. Never throws - callers treat teardown as best effort.
|
||||
*/
|
||||
export async function teardownPushNotifications(): Promise<void> {
|
||||
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
@@ -266,7 +266,7 @@ export interface NotificationTapPayload {
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
// Returns — and clears — any pending "notification tap" that launched the app
|
||||
// Returns - and clears - any pending "notification tap" that launched the app
|
||||
// before JS was ready to handle it. Subsequent taps while running are delivered
|
||||
// via addNotificationTapListener.
|
||||
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
|
||||
|
||||
@@ -210,7 +210,7 @@ export default function CalendarScreen() {
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
if (!action) return;
|
||||
// For the first pass, treat all scopes as "all" — proper override
|
||||
// For the first pass, treat all scopes as "all" - proper override
|
||||
// handling lives in the store's update/delete path against originalId.
|
||||
// TODO: implement "this only" via recurrenceOverrides write, and
|
||||
// "this and following" via excludedRecurrenceRules + new master.
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function ContactsScreen() {
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list — single unnamed section keeps SectionList rendering simple.
|
||||
// Flat list - single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
|
||||
@@ -510,7 +510,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Compose FAB — matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||
<Pressable
|
||||
onPress={onComposePress}
|
||||
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
||||
@@ -749,7 +749,7 @@ function TriToggle({
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
|
||||
// Header — matches web mobile-header: h-14 (56px), px-4, border-b
|
||||
// Header - matches web mobile-header: h-14 (56px), px-4, border-b
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -770,7 +770,7 @@ const styles = StyleSheet.create({
|
||||
height: 28,
|
||||
},
|
||||
|
||||
// Search — matches web search bar styling
|
||||
// Search - matches web search bar styling
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1026,7 +1026,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
// Email list — matches web email-list-item
|
||||
// Email list - matches web email-list-item
|
||||
listContent: { paddingBottom: 100 },
|
||||
emailRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -1111,7 +1111,7 @@ const styles = StyleSheet.create({
|
||||
tagText: { ...typography.small, fontWeight: '500' },
|
||||
separator: { height: 0 }, // borders are on rows now
|
||||
|
||||
// Compose FAB — matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
|
||||
// Compose FAB - matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: spacing.lg, // right-4
|
||||
@@ -1119,7 +1119,7 @@ const styles = StyleSheet.create({
|
||||
width: componentSizes.fab, // 56px (h-14)
|
||||
height: componentSizes.fab, // 56px (w-14)
|
||||
borderRadius: radius.full, // rounded-full (circle)
|
||||
backgroundColor: colors.text, // white — matches webmail mobile FAB
|
||||
backgroundColor: colors.text, // white - matches webmail mobile FAB
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 6,
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
) : null}
|
||||
<Text style={styles.senderRecipients} numberOfLines={1}>
|
||||
<Text style={styles.senderRecipientsLabel}>to </Text>
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '—'}
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
@@ -143,7 +143,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab list — matches webmail mobile: flat grouped list, no cards */}
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.tabList}>
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
@@ -198,7 +198,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Logout — matches webmail: border-t, destructive text, icon + label */}
|
||||
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={onLogout}
|
||||
@@ -219,7 +219,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// Header — webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -259,7 +259,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group divider — webmail: mx-5 my-2 border-t border-border
|
||||
// Group divider - webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
@@ -267,7 +267,7 @@ const styles = StyleSheet.create({
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group header — webmail: px-5 pt-3 pb-1.5
|
||||
// Group header - webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
@@ -281,7 +281,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Tab item — webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -303,7 +303,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// Experimental badge — webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
@@ -334,7 +334,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout — webmail: border-t border-border, px-5 py-3, destructive text
|
||||
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
|
||||
@@ -104,7 +104,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (serverUrl, username, password, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Adding an additional account — snapshot/reset feature stores so the
|
||||
// Adding an additional account - snapshot/reset feature stores so the
|
||||
// new account starts with a clean slate.
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
@@ -221,7 +221,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing — evict stale entry and surface error
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
@@ -250,7 +250,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
try {
|
||||
// Wait for persisted caches to finish hydrating from AsyncStorage.
|
||||
// Otherwise we read empty defaults and bounce the user back to the
|
||||
// login screen — and the feature stores don't have their cached data
|
||||
// login screen - and the feature stores don't have their cached data
|
||||
// yet when refetchFeatureStores() checks currentMailboxId / loadedRange
|
||||
// at the end of this function.
|
||||
await Promise.all([
|
||||
|
||||
@@ -122,7 +122,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before };
|
||||
const ids = calendars.map((c) => c.id);
|
||||
if (ids.length === 0) {
|
||||
// Calendars haven't loaded yet — fetch them, then events.
|
||||
// Calendars haven't loaded yet - fetch them, then events.
|
||||
await get().fetchCalendars();
|
||||
}
|
||||
const calendarIdsAfter = get().calendars.map((c) => c.id);
|
||||
|
||||
@@ -114,7 +114,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
selectMailbox: async (mailboxId) => {
|
||||
// Reset search/filters when switching mailbox — matches webmail behavior.
|
||||
// Reset search/filters when switching mailbox - matches webmail behavior.
|
||||
set({
|
||||
currentMailboxId: mailboxId,
|
||||
loading: true,
|
||||
@@ -157,7 +157,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
refreshEmails: async () => {
|
||||
const { currentMailboxId, searchQuery, filters } = get();
|
||||
if (!currentMailboxId) return;
|
||||
// Keep existing emails visible while refreshing — swap atomically once
|
||||
// Keep existing emails visible while refreshing - swap atomically once
|
||||
// the new list is ready so pull-to-refresh doesn't flash an empty list.
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
// Qui Dark Theme — exact values from Bulwark Webmail's builtin-themes.ts
|
||||
// Qui Dark Theme - exact values from Bulwark Webmail's builtin-themes.ts
|
||||
export const colors = {
|
||||
// Primary brand (Qui theme)
|
||||
primary: '#3b82f6',
|
||||
@@ -69,7 +69,7 @@ export const colors = {
|
||||
orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark
|
||||
},
|
||||
|
||||
// Tag colors — from KEYWORD_PALETTE in stores/settings-store.ts
|
||||
// Tag colors - from KEYWORD_PALETTE in stores/settings-store.ts
|
||||
// dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400
|
||||
tags: {
|
||||
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
|
||||
@@ -87,7 +87,7 @@ export const colors = {
|
||||
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
|
||||
},
|
||||
|
||||
// Navigation — matches web nav-rail
|
||||
// Navigation - matches web nav-rail
|
||||
navActive: '#3b82f6',
|
||||
navInactive: '#71717a', // --color-muted-foreground
|
||||
navBadge: '#ef4444', // red-500
|
||||
@@ -145,18 +145,18 @@ export const typography = {
|
||||
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl
|
||||
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl
|
||||
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg
|
||||
// Body = text-sm (14px) — the web uses text-sm for almost all content
|
||||
// Body = text-sm (14px) - the web uses text-sm for almost all content
|
||||
body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
|
||||
bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 },
|
||||
bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 },
|
||||
bodyBold: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 },
|
||||
// Base = text-base (16px) — used for email content body
|
||||
// Base = text-base (16px) - used for email content body
|
||||
base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
|
||||
baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 },
|
||||
// Caption = text-xs (12px)
|
||||
caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 },
|
||||
captionMedium: { fontSize: 12, fontWeight: '500' as const, lineHeight: 16 },
|
||||
// Small = 10px — for tags, badges, tab labels
|
||||
// Small = 10px - for tags, badges, tab labels
|
||||
small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user