diff --git a/locales/rn/en.json b/locales/rn/en.json index 6f08a47e..a629c21a 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -299,6 +299,7 @@ "notice_need_code": "Enter the 6-digit code from your authenticator app", "notice_need_credentials": "Enter your email and password", "notice_need_server": "We need a server address first", + "open_settings": "Open Settings", "opening": "Opening {host}", "opening_detail": "Finish signing in on the page that just opened. We’ll take it from there.", "pairing": "Redeeming your sign-in code", diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 4d97a61e..5a89531b 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -67,8 +67,10 @@ describe('calendar operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[0]).toBe('CalendarEvent/query'); + // No bounds: only the calendar restriction is sent. expect(call[1].filter).toEqual({ inCalendar: 'cal-1' }); expect(call[1].accountId).toBe('acc-1'); + expect(call[1].position).toBe(0); }); it('should send the date window as LocalDateTime after/before combined with the calendar filter', async () => { @@ -99,6 +101,28 @@ describe('calendar operations', () => { expect(Object.keys(call[1].filter).sort()).toEqual(['after', 'before']); }); + it('should page through a result set larger than one page', async () => { + // Regression: a full page used to be returned as the complete answer, so + // an account with more events than the page size silently lost every + // event past it - the calendar rendered empty. + const first = Array.from({ length: 1000 }, (_, i) => `a${i}`); + mockRequest + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: first }, '0']], + }) + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']], + }); + + const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z'); + + expect(result).toHaveLength(1002); + expect(result[1001]).toBe('tail-2'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0); + expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000); + }); + it('should OR multiple calendars and respect the target account', async () => { mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], @@ -114,7 +138,7 @@ describe('calendar operations', () => { expect(call[1].accountId).toBe('acc-shared'); }); - it('should omit the filter when no calendars are given', async () => { + it('should omit the filter when no calendars are given and window is empty', async () => { mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], }); diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index 085232ac..09307755 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -91,7 +91,7 @@ describe('JMAPClient', () => { await client.connect('https://mail.example.com///', 'user', 'pass'); expect(global.fetch).toHaveBeenCalledWith( - 'https://mail.example.com/.well-known/jmap', + 'https://mail.example.com/jmap/session', expect.any(Object), ); }); @@ -282,7 +282,9 @@ describe('JMAPClient', () => { expect(client.accountId).toBe('only-acc'); }); - it('should throw if no accounts at all', async () => { + it('should throw on an empty (unauthenticated) session', async () => { + // Stalwart returns 200 with empty accounts for invalid credentials; + // the client surfaces that as an auth failure. const session = { ...MOCK_SESSION, primaryAccounts: {}, @@ -291,7 +293,7 @@ describe('JMAPClient', () => { global.fetch = mockFetch([{ status: 200, json: session }]) as any; await expect(client.connect('https://mail.example.com', 'user', 'pass')) - .rejects.toThrow('No account found'); + .rejects.toThrow('Invalid credentials'); }); }); }); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index bb7db1e5..a8655a2a 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -248,6 +248,11 @@ export async function getCalendars(): Promise { return [...own, ...shared.flat()]; } +const QUERY_PAGE_SIZE = 1000; +// Backstop against a server that keeps returning full pages; 20 pages is far +// beyond any real calendar window and keeps a bug from looping forever. +const QUERY_MAX_PAGES = 20; + /** * Query event ids in a date window. `after`/`before` are instants (ISO, as * from `Date.toISOString()`) or LocalDateTime strings; they're sent as @@ -256,6 +261,8 @@ export async function getCalendars(): Promise { * condition (the plural `inCalendars` fails the whole query with * unsupportedFilter on Stalwart); the range and calendar conditions are * combined with an AND operator. Without bounds every object is returned. + * The result set is paged: a window can hold more events than one page, and + * a truncated page must never pass for a complete answer. */ export async function queryEvents( calendarIds: string[], @@ -264,22 +271,36 @@ export async function queryEvents( accountId?: string, ): Promise { const account = accountId || jmapClient.accountId; - const args: Record = { accountId: account, limit: 1000 }; const timeZone = getUserTimeZone(); - if (timeZone) args.timeZone = timeZone; const conditions: Record[] = []; if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); const range: Record = {}; if (after) range.after = toLocalDateTime(after, timeZone); if (before) range.before = toLocalDateTime(before, timeZone); if (Object.keys(range).length > 0) conditions.push(range); - if (conditions.length === 1) args.filter = conditions[0]; - else if (conditions.length > 1) args.filter = { operator: 'AND', conditions }; - const res = await jmapClient.request( - [['CalendarEvent/query', args, '0']], - USING, - ); - return methodResult<{ ids: string[] }>(res).ids ?? []; + const filter = conditions.length === 1 + ? conditions[0] + : conditions.length > 1 + ? { operator: 'AND', conditions } + : undefined; + const ids: string[] = []; + for (let page = 0; page < QUERY_MAX_PAGES; page++) { + const args: Record = { + accountId: account, + limit: QUERY_PAGE_SIZE, + position: ids.length, + }; + if (timeZone) args.timeZone = timeZone; + if (filter) args.filter = filter; + const res = await jmapClient.request( + [['CalendarEvent/query', args, '0']], + USING, + ); + const batch = methodResult<{ ids: string[] }>(res).ids ?? []; + ids.push(...batch); + if (batch.length < QUERY_PAGE_SIZE) break; + } + return ids; } /** diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 4fdfbfaf..2fb2a5b4 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -691,10 +691,21 @@ export class JMAPClient { // ── Session Discovery ───────────────────────────────── private async fetchSession(baseUrl: string): Promise { - const url = `${baseUrl}/.well-known/jmap`; - let response = await this.authenticatedFetch(url, { - headers: { Accept: 'application/json' }, - }); + // Stalwart's discovery endpoint /.well-known/jmap 307-redirects to + // /jmap/session. On iOS, NSURLSession drops the Authorization header when + // it auto-follows that redirect, so the app receives an unauthenticated, + // empty-accounts session. Request the session endpoint directly so the + // header stays attached; fall back to the standard well-known path for + // servers that don't serve /jmap/session (#39). + const primary = `${baseUrl}/jmap/session`; + const fallback = `${baseUrl}/.well-known/jmap`; + const fetchSessionDoc = async () => { + const r = await this.authenticatedFetch(primary, { headers: { Accept: 'application/json' } }); + return r.status === 404 + ? this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } }) + : r; + }; + let response = await fetchSessionDoc(); if (response.status === 401) { throw new AuthenticationError('Invalid credentials'); @@ -741,6 +752,15 @@ export class JMAPClient { if (!fetchSessionDescribes(session)) { throw new Error('Session discovery failed: response is not a JMAP session'); } + // Stalwart answers 200 with an empty session (no accounts) for missing or + // invalid credentials rather than a 401. Surface that as an auth failure + // so the user sees "Invalid credentials" instead of "No account found". + const hasAccount = + Object.keys(session.primaryAccounts ?? {}).length > 0 || + Object.keys(session.accounts ?? {}).length > 0; + if (!hasAccount) { + throw new AuthenticationError('Invalid credentials'); + } return session; } diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index e3279014..156788de 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native'; +import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera'; import { X } from 'lucide-react-native'; @@ -25,6 +25,8 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { // Guards against the camera firing onBarcodeScanned dozens of times for the // same code before the modal tears down. const handled = React.useRef(false); + // Once iOS/Android stops letting us re-prompt, the only path left is Settings. + const blocked = !!permission && !permission.granted && !permission.canAskAgain; React.useEffect(() => { if (visible) handled.current = false; @@ -84,12 +86,16 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { {!permission?.granted ? ( - {permission && !permission.canAskAgain - ? t('login.mobile.camera_disabled', 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.') - : t('login.mobile.camera_needed', 'Bulwark Mail needs camera access to scan a sign-in QR code.')} + {blocked + ? t('login.mobile.camera_disabled', 'Camera access is turned off. Turn it on in Settings to scan a sign-in QR code.') + : t('login.mobile.camera_needed', 'Scanning a sign-in QR code uses the camera. The QR code is read on this device only.')} - ) : null} diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 446a464b..e2d4e6ec 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -677,19 +677,28 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const openDuration = useAnimDuration(240); const closeDuration = useAnimDuration(200); + // Runs the slide-in. Kicked from both the visible effect and the Modal's + // onShow: on the very first open the modal's native view is not attached yet + // when the effect fires, so that first animation is dropped and the drawer + // stays parked off-screen. Re-running it once the modal is on screen commits + // the final offset (a no-op on every subsequent open). + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }), + ]).start(); + }, [slideX, overlayOpacity, openDuration]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }), - ]).start(); + runOpen(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }), ]).start(); } - }, [visible, slideX, overlayOpacity, openDuration, closeDuration]); + }, [visible, runOpen, slideX, overlayOpacity, closeDuration]); const accountEmail = username || ''; const initials = React.useMemo( @@ -735,6 +744,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) animationType="none" statusBarTranslucent onRequestClose={onClose} + onShow={runOpen} > diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index d56bfa3c..97fa70f9 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -73,21 +73,28 @@ export function CalendarSidebarDrawer({ const t = useLocaleStore((s) => s.t); const [expandedId, setExpandedId] = React.useState(null); + // Also kicked from the Modal's onShow — the first open fires this effect + // before the modal's native view exists, so that animation is dropped and + // the drawer stays parked off-screen until the second open. + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { + toValue: 0, + duration: openDuration, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(overlayOpacity, { + toValue: 1, + duration: openDuration, + useNativeDriver: true, + }), + ]).start(); + }, [slideX, overlayOpacity, openDuration]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { - toValue: 0, - duration: openDuration, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(overlayOpacity, { - toValue: 1, - duration: openDuration, - useNativeDriver: true, - }), - ]).start(); + runOpen(); } else { setExpandedId(null); Animated.parallel([ @@ -104,7 +111,7 @@ export function CalendarSidebarDrawer({ }), ]).start(); } - }, [visible, slideX, overlayOpacity, openDuration, closeDuration]); + }, [visible, runOpen, slideX, overlayOpacity, closeDuration]); const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]); @@ -134,6 +141,7 @@ export function CalendarSidebarDrawer({ animationType="none" statusBarTranslucent onRequestClose={onClose} + onShow={runOpen} > diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 0f1b0524..91821da2 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -102,19 +102,26 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const overlay = React.useRef(new Animated.Value(0)).current; + // Also kicked from the Modal's onShow — the first open fires this effect + // before the modal's native view exists, so that animation is dropped and + // the drawer stays parked off-screen until the second open. + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }), + ]).start(); + }, [slideX, overlay]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }), - ]).start(); + runOpen(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlay, { toValue: 0, duration: 200, useNativeDriver: true }), ]).start(); } - }, [visible, slideX, overlay]); + }, [visible, runOpen, slideX, overlay]); const select = (cat: ContactCategory) => { setSelectedCategory(cat); @@ -150,7 +157,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { ); return ( - +