mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
chore: merge upstream fixes for reply threading, iOS session auth drop, calendar paging and the camera prompt (#35, #38, #39, #40)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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']],
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+30
-9
@@ -248,6 +248,11 @@ export async function getCalendars(): Promise<Calendar[]> {
|
||||
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<Calendar[]> {
|
||||
* 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<string[]> {
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
|
||||
const timeZone = getUserTimeZone();
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
|
||||
const range: Record<string, string> = {};
|
||||
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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+24
-4
@@ -691,10 +691,21 @@ export class JMAPClient {
|
||||
// ── Session Discovery ─────────────────────────────────
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<View style={styles.permissionWrap}>
|
||||
<Text style={styles.permissionText}>
|
||||
{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.')}
|
||||
</Text>
|
||||
<Button variant="default" size="md" onPress={() => void requestPermission()}>
|
||||
{t('login.mobile.camera_allow', 'Allow camera access')}
|
||||
<Button
|
||||
variant="default"
|
||||
size="md"
|
||||
onPress={() => void (blocked ? Linking.openSettings() : requestPermission())}
|
||||
>
|
||||
{blocked ? t('login.mobile.open_settings', 'Open Settings') : t('login.mobile.continue', 'Continue')}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
|
||||
@@ -73,21 +73,28 @@ export function CalendarSidebarDrawer({
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(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}
|
||||
>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
|
||||
@@ -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 (
|
||||
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
Reference in New Issue
Block a user