mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd476e052 | ||
|
|
41793455f7 | ||
|
|
d99cbf1dbb | ||
|
|
1afd2185d5 | ||
|
|
5aa1c88b1e | ||
|
|
ac95a2bc39 |
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
@@ -21,6 +22,12 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
|
||||
+12
-1
@@ -46,7 +46,18 @@ module.exports = {
|
||||
web: {
|
||||
favicon: './assets/favicon.png',
|
||||
},
|
||||
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
|
||||
plugins: [
|
||||
'expo-secure-store',
|
||||
'@react-native-community/datetimepicker',
|
||||
'expo-localization',
|
||||
[
|
||||
'expo-camera',
|
||||
{
|
||||
cameraPermission:
|
||||
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
|
||||
},
|
||||
],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
},
|
||||
|
||||
Generated
+23
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.45",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.45",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
@@ -5211,6 +5212,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.45",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform } 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';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
|
||||
interface QrScanModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
// Fires once with the raw decoded QR string. The parent is responsible for
|
||||
// closing the modal and interpreting the payload.
|
||||
onScanned: (data: string) => void;
|
||||
}
|
||||
|
||||
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
// Guards against the camera firing onBarcodeScanned dozens of times for the
|
||||
// same code before the modal tears down.
|
||||
const handled = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) handled.current = false;
|
||||
}, [visible]);
|
||||
|
||||
const handleBarcode = React.useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (handled.current) return;
|
||||
handled.current = true;
|
||||
onScanned(result.data);
|
||||
},
|
||||
[onScanned],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<View style={styles.container}>
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFill}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarcode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SafeAreaView style={styles.overlay}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
|
||||
<X size={24} color="#ffffff" />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Scan QR code</Text>
|
||||
<View style={styles.closeButton} />
|
||||
</View>
|
||||
|
||||
{permission?.granted ? (
|
||||
<View style={styles.frameWrap}>
|
||||
<View style={styles.frame} />
|
||||
<Text style={styles.hint}>
|
||||
Point your camera at the QR code shown on the webmail settings screen.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.permissionWrap}>
|
||||
<Text style={styles.permissionText}>
|
||||
{permission && !permission.canAskAgain
|
||||
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
|
||||
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
|
||||
</Text>
|
||||
<Button variant="default" size="md" onPress={() => void requestPermission()}>
|
||||
Allow camera access
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
overlay: { flex: 1, justifyContent: 'flex-start' },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
title: { ...typography.h3, color: '#ffffff' },
|
||||
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
|
||||
frame: {
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
permissionWrap: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.lg,
|
||||
paddingHorizontal: spacing.xxl,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
|
||||
});
|
||||
}
|
||||
@@ -123,6 +123,90 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
|
||||
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
|
||||
}
|
||||
|
||||
// QR login payloads. A QR scanned on the login screen either bootstraps the
|
||||
// server URL for the normal webmail handoff (`connect`), or carries a one-time
|
||||
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
|
||||
// The payload never contains credentials — the `pair` code is redeemed for
|
||||
// tokens over the network, once.
|
||||
export type QrLoginPayload =
|
||||
| { kind: 'connect'; webmailUrl: string }
|
||||
| { kind: 'pair'; webmailUrl: string; code: string };
|
||||
|
||||
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
|
||||
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
|
||||
if (match) {
|
||||
const kind = match[1].toLowerCase();
|
||||
const params = new URLSearchParams(match[2]);
|
||||
const server = params.get('server');
|
||||
if (!server || !/^https?:\/\//i.test(server)) return null;
|
||||
if (kind === 'pair') {
|
||||
const code = params.get('code');
|
||||
if (!code) return null;
|
||||
return { kind: 'pair', webmailUrl: server, code };
|
||||
}
|
||||
return { kind: 'connect', webmailUrl: server };
|
||||
}
|
||||
|
||||
// A bare https URL is treated as a server-bootstrap target so admins can
|
||||
// hand out a plain webmail URL QR without the custom-scheme wrapper.
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return { kind: 'connect', webmailUrl: trimmed };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cross-device pairing redemption. Posts the scanned code to the webmail that
|
||||
// minted it and maps the token bundle into the same shape the in-browser OAuth
|
||||
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
|
||||
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ pairing_code: code }),
|
||||
});
|
||||
} catch {
|
||||
throw new HandoffError('Could not reach the server to complete pairing');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// The code was unknown, already used, or expired (server returns 400) —
|
||||
// or something else went wrong. Either way the user needs a fresh QR.
|
||||
throw new HandoffError('Pairing code is invalid or has expired');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
server_url?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_endpoint?: string;
|
||||
client_id?: string;
|
||||
};
|
||||
|
||||
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
|
||||
throw new HandoffError('Pairing response missing token material');
|
||||
}
|
||||
|
||||
return {
|
||||
flow: 'oauth',
|
||||
serverUrl: data.server_url,
|
||||
tokens: {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? undefined,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: data.token_endpoint,
|
||||
clientId: data.client_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// OAuth refresh — exchanges the refresh token at the original token endpoint
|
||||
// for a new access token. Returns the updated bundle so the caller can
|
||||
// persist it.
|
||||
|
||||
+341
-199
@@ -64,7 +64,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
// swiping / Prev-Next can switch messages in place without remounting the
|
||||
// screen — which is what produced the loading flash on every change.
|
||||
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
|
||||
const [activeSubject, setActiveSubject] = React.useState(route.params.subject ?? '');
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
|
||||
@@ -80,27 +79,39 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
|
||||
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
|
||||
|
||||
const [email, setEmail] = React.useState<Email | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
||||
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
|
||||
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
|
||||
|
||||
// In-memory cache of fetched message details keyed by id. Lets us show a
|
||||
// neighbour instantly (no spinner) when switching, and prefetch adjacent
|
||||
// messages so a swipe lands on ready content.
|
||||
// In-memory cache of fetched message details keyed by id. This is the single
|
||||
// source of truth for every rendered pane: the active message *and* its
|
||||
// prefetched neighbours all read their detail from here, so a swipe lands on
|
||||
// ready content with no spinner and no late content-swap. `cacheVersion` is
|
||||
// bumped whenever an entry changes so the panes re-render.
|
||||
const detailCache = React.useRef(new Map<string, Email>()).current;
|
||||
const [cacheVersion, setCacheVersion] = React.useState(0);
|
||||
const bumpCache = React.useCallback(() => setCacheVersion((v) => v + 1), []);
|
||||
const email = React.useMemo(
|
||||
() => detailCache.get(activeEmailId) ?? null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[activeEmailId, cacheVersion, detailCache],
|
||||
);
|
||||
|
||||
// --- Swipe / slide navigation between adjacent emails -------------------
|
||||
// translateX drives both the finger-follow drag and the slide-out/slide-in
|
||||
// transition. Refs keep the (memoised once) PanResponder reading the latest
|
||||
// A three-pane pager: [prev | current | next] are laid out in a single row of
|
||||
// width 3×screen and kept mounted side by side. `drag` translates the whole
|
||||
// row so the neighbour's real content follows the finger — no blank gap and
|
||||
// no late content-swap. At rest the row is shifted left by one screen so the
|
||||
// middle (current) pane sits on screen. After a settled swipe we re-point
|
||||
// `activeEmailId` at the neighbour and reset `drag` to 0 in the same commit;
|
||||
// because the landed pane already shows that exact message, the recentre is
|
||||
// seamless. Refs keep the (memoised once) PanResponder reading the latest
|
||||
// neighbours / dimensions without being recreated every render.
|
||||
const translateX = React.useRef(new Animated.Value(0)).current;
|
||||
const scrollRef = React.useRef<ScrollView>(null);
|
||||
const drag = React.useRef(new Animated.Value(0)).current;
|
||||
const animatingRef = React.useRef(false);
|
||||
const pendingRecentreRef = React.useRef(false);
|
||||
const prevEmailRef = React.useRef(prevEmail);
|
||||
const nextEmailRef = React.useRef(nextEmail);
|
||||
const windowWidthRef = React.useRef(windowWidth);
|
||||
@@ -113,24 +124,43 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
if (!target || animatingRef.current) return;
|
||||
animatingRef.current = true;
|
||||
const w = windowWidthRef.current;
|
||||
// Bring the neighbour pane to centre: next sits to the right (slide row
|
||||
// left → -w), prev sits to the left (slide row right → +w).
|
||||
const out = direction === 'next' ? -w : w;
|
||||
// Slide the current message the rest of the way out, swap content, then
|
||||
// slide the new message in from the opposite edge.
|
||||
Animated.timing(translateX, {
|
||||
toValue: out, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true,
|
||||
}).start(() => {
|
||||
|
||||
// Best-effort: ensure the target detail is cached so the recentred pane has
|
||||
// content. Neighbours are normally prefetched, so this is usually a no-op.
|
||||
if (!detailCache.has(target.id)) {
|
||||
getEmailDetail(target.id, jmapAccountId)
|
||||
.then((e) => { detailCache.set(target.id, e); bumpCache(); })
|
||||
.catch(() => { /* the load effect will retry once it becomes active */ });
|
||||
}
|
||||
|
||||
Animated.timing(drag, {
|
||||
toValue: out, duration: 230, easing: Easing.out(Easing.cubic), useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (!finished) { animatingRef.current = false; return; }
|
||||
// Re-point at the neighbour; the drag reset happens in the layout effect
|
||||
// below, in the same commit that re-keys the panes. Resetting here would
|
||||
// recentre the row while the *old* arrangement is still on screen,
|
||||
// flashing the previous email for a frame until React catches up.
|
||||
pendingRecentreRef.current = true;
|
||||
setActiveEmailId(target.id);
|
||||
setActiveSubject(target.subject ?? '');
|
||||
scrollRef.current?.scrollTo({ y: 0, animated: false });
|
||||
translateX.setValue(-out);
|
||||
Animated.timing(translateX, {
|
||||
toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true,
|
||||
}).start(() => { animatingRef.current = false; });
|
||||
});
|
||||
}, [translateX]);
|
||||
}, [drag, detailCache, getEmailDetail, jmapAccountId, bumpCache]);
|
||||
const switchEmailRef = React.useRef(switchEmail);
|
||||
switchEmailRef.current = switchEmail;
|
||||
|
||||
// Recentre the row in the same commit that re-pointed the panes: the pane
|
||||
// that slid to centre is now the middle pane (same key, same mounted
|
||||
// WebView), so snapping the translation back to 0 is invisible.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!pendingRecentreRef.current) return;
|
||||
pendingRecentreRef.current = false;
|
||||
drag.setValue(0);
|
||||
animatingRef.current = false;
|
||||
}, [activeEmailId, drag]);
|
||||
|
||||
const panResponder = React.useRef(
|
||||
PanResponder.create({
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
@@ -146,7 +176,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
if ((dx > 0 && !prevEmailRef.current) || (dx < 0 && !nextEmailRef.current)) {
|
||||
dx *= 0.25;
|
||||
}
|
||||
translateX.setValue(dx);
|
||||
drag.setValue(dx);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
const w = windowWidthRef.current;
|
||||
@@ -157,11 +187,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
} else if ((g.dx >= threshold || (fast && g.vx > 0)) && prevEmailRef.current) {
|
||||
switchEmailRef.current('prev');
|
||||
} else {
|
||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
Animated.spring(drag, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
Animated.spring(drag, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
},
|
||||
onPanResponderTerminationRequest: () => true,
|
||||
}),
|
||||
@@ -184,14 +214,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
);
|
||||
|
||||
const onPressAttachment = React.useCallback(
|
||||
async (blobId: string, name: string | undefined, type: string | undefined) => {
|
||||
async (target: Email, blobId: string, name: string | undefined, type: string | undefined) => {
|
||||
if (downloadingBlobId) return;
|
||||
setDownloadingBlobId(blobId);
|
||||
try {
|
||||
if (mailAttachmentAction === 'download') {
|
||||
await downloadAttachment(blobId, name, type, email);
|
||||
await downloadAttachment(blobId, name, type, target);
|
||||
} else {
|
||||
await shareAttachment(blobId, name, type, email);
|
||||
await shareAttachment(blobId, name, type, target);
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
|
||||
@@ -199,66 +229,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
setDownloadingBlobId(null);
|
||||
}
|
||||
},
|
||||
[downloadingBlobId, mailAttachmentAction, email],
|
||||
[downloadingBlobId, mailAttachmentAction],
|
||||
);
|
||||
|
||||
const renderAttachments = () => {
|
||||
const all = email?.attachments;
|
||||
if (!all || all.length === 0) return null;
|
||||
// hideInlineImageAttachments: drop chips for images already shown inline
|
||||
// in the body (any image attachment with a cid:). Non-image inline
|
||||
// attachments stay visible because the user wouldn't see them otherwise.
|
||||
const atts = hideInlineImageAttachments
|
||||
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
|
||||
: all;
|
||||
if (atts.length === 0) return null;
|
||||
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
|
||||
return (
|
||||
<View style={styles.attachmentsBlock}>
|
||||
<View style={styles.attachmentsRow}>
|
||||
{visible.map((att, idx) => {
|
||||
const isDownloading = downloadingBlobId === att.blobId;
|
||||
return (
|
||||
<Pressable
|
||||
key={att.blobId ?? idx}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentChip,
|
||||
pressed && styles.attachmentChipPressed,
|
||||
]}
|
||||
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
|
||||
disabled={!!downloadingBlobId}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Paperclip size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||
{att.name || 'attachment'}
|
||||
</Text>
|
||||
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{atts.length > 3 && (
|
||||
<Pressable
|
||||
onPress={() => setAttachmentsExpanded((v) => !v)}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentsToggle,
|
||||
pressed && styles.attachmentsTogglePressed,
|
||||
]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.attachmentsToggleText}>
|
||||
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
@@ -268,23 +240,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
let cancelled = false;
|
||||
let readTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// If we already have this message cached (e.g. it was prefetched as a
|
||||
// neighbour), show it immediately — no spinner — and refresh in the
|
||||
// background. Otherwise fall back to the loading state.
|
||||
const cached = detailCache.get(activeEmailId);
|
||||
if (cached) {
|
||||
setEmail(cached);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
// neighbour) it renders immediately; otherwise the pane shows a skeleton
|
||||
// until the fetch below lands. Either way, refresh in the background.
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const fetched = await getEmailDetail(activeEmailId, jmapAccountId);
|
||||
if (cancelled) return;
|
||||
detailCache.set(activeEmailId, fetched);
|
||||
setEmail(fetched);
|
||||
bumpCache();
|
||||
if (fetched && !fetched.keywords?.$seen) {
|
||||
if (markAsReadDelay > 0) {
|
||||
readTimer = setTimeout(() => {
|
||||
@@ -300,27 +264,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
if (!cancelled && !detailCache.has(activeEmailId)) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load email');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (readTimer) clearTimeout(readTimer);
|
||||
};
|
||||
}, [activeEmailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay, detailCache]);
|
||||
}, [activeEmailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay, detailCache, bumpCache]);
|
||||
|
||||
// Prefetch the adjacent messages so a swipe / Prev-Next lands on ready
|
||||
// content instead of a spinner. Best-effort and silent.
|
||||
// Prefetch the adjacent messages so their pane is already populated when the
|
||||
// pager slides it into view. Best-effort and silent; bump the cache version
|
||||
// on arrival so the off-screen neighbour pane renders its content.
|
||||
React.useEffect(() => {
|
||||
for (const neighbour of [prevEmail, nextEmail]) {
|
||||
if (neighbour && !detailCache.has(neighbour.id)) {
|
||||
getEmailDetail(neighbour.id, jmapAccountId)
|
||||
.then((e) => detailCache.set(neighbour.id, e))
|
||||
.then((e) => { detailCache.set(neighbour.id, e); bumpCache(); })
|
||||
.catch(() => { /* ignore — prefetch is best-effort */ });
|
||||
}
|
||||
}
|
||||
}, [prevEmail, nextEmail, getEmailDetail, jmapAccountId, detailCache]);
|
||||
}, [prevEmail, nextEmail, getEmailDetail, jmapAccountId, detailCache, bumpCache]);
|
||||
|
||||
const starred = !!email?.keywords?.$flagged;
|
||||
const unread = !!email && !email.keywords?.$seen;
|
||||
@@ -339,14 +302,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
);
|
||||
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
|
||||
|
||||
const updateLocalKeywords = (next: Record<string, boolean>) => {
|
||||
setEmail((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updated = { ...prev, keywords: next };
|
||||
// Keep the cached copy in sync so swiping back shows the toggled state.
|
||||
detailCache.set(prev.id, updated);
|
||||
return updated;
|
||||
});
|
||||
// Optimistically write a message's keywords into the cache (the single source
|
||||
// of truth for every pane) and bump the version so the panes re-render.
|
||||
const updateLocalKeywords = (id: string, next: Record<string, boolean>) => {
|
||||
const prev = detailCache.get(id);
|
||||
if (!prev) return;
|
||||
detailCache.set(id, { ...prev, keywords: next });
|
||||
bumpCache();
|
||||
};
|
||||
|
||||
const onToggleKeyword = (token: string) => {
|
||||
@@ -354,28 +316,32 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const next = { ...email.keywords };
|
||||
if (next[token]) delete next[token];
|
||||
else next[token] = true;
|
||||
updateLocalKeywords(next);
|
||||
updateLocalKeywords(email.id, next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
};
|
||||
|
||||
const onToggleStar = () => {
|
||||
if (!email) return;
|
||||
const next = { ...email.keywords };
|
||||
if (starred) delete next.$flagged;
|
||||
// Toggle the star on a specific message — used both by the toolbar (current
|
||||
// message) and by each pane's own subject star.
|
||||
const toggleStarFor = React.useCallback((target: Email) => {
|
||||
const next = { ...target.keywords };
|
||||
if (next.$flagged) delete next.$flagged;
|
||||
else next.$flagged = true;
|
||||
updateLocalKeywords(next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
};
|
||||
const prev = detailCache.get(target.id);
|
||||
if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); }
|
||||
void setEmailKeywords(target.id, next);
|
||||
}, [detailCache, bumpCache]);
|
||||
|
||||
const onToggleStar = () => { if (email) toggleStarFor(email); };
|
||||
|
||||
const onToggleUnread = () => {
|
||||
if (!email) return;
|
||||
if (unread) {
|
||||
void markRead(email.id);
|
||||
updateLocalKeywords({ ...email.keywords, $seen: true });
|
||||
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
|
||||
} else {
|
||||
const next = { ...email.keywords };
|
||||
delete next.$seen;
|
||||
updateLocalKeywords(next);
|
||||
updateLocalKeywords(email.id, next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
}
|
||||
};
|
||||
@@ -507,8 +473,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
},
|
||||
};
|
||||
|
||||
const subject = email?.subject ?? activeSubject ?? '(no subject)';
|
||||
const from = email?.from?.[0];
|
||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||
|
||||
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
|
||||
@@ -579,83 +543,42 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : error ? (
|
||||
{error && !email ? (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : email ? (
|
||||
) : (
|
||||
<>
|
||||
<Animated.View
|
||||
style={[styles.slidePane, { transform: [{ translateX }] }]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
|
||||
>
|
||||
{/* Subject block */}
|
||||
<View style={styles.subjectBlock}>
|
||||
<View style={styles.subjectRow}>
|
||||
<View style={styles.subjectMain}>
|
||||
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
|
||||
<Star
|
||||
size={18}
|
||||
color={starred ? c.starred : c.textMuted}
|
||||
fill={starred ? c.starred : 'transparent'}
|
||||
{/* Three-pane pager: [prev | current | next] live side by side in a
|
||||
row shifted left by one screen, so a horizontal drag slides the
|
||||
neighbour's already-rendered content into view with no gap. */}
|
||||
<View style={styles.pagerViewport}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pagerRow,
|
||||
{ width: windowWidth * 3, left: -windowWidth, transform: [{ translateX: drag }] },
|
||||
]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{[prevEmail?.id ?? null, activeEmailId, nextEmail?.id ?? null].map((id, i) => (
|
||||
<View key={id ?? `empty-${i}`} style={{ width: windowWidth }}>
|
||||
{id ? (
|
||||
<EmailPane
|
||||
email={detailCache.get(id) ?? null}
|
||||
c={c}
|
||||
styles={styles}
|
||||
bottomBarHeight={bottomBarHeight}
|
||||
attachmentPosition={attachmentPosition}
|
||||
hideInlineImageAttachments={hideInlineImageAttachments}
|
||||
downloadingBlobId={downloadingBlobId}
|
||||
onToggleStar={toggleStarFor}
|
||||
onPressAttachment={onPressAttachment}
|
||||
/>
|
||||
</Pressable>
|
||||
<Text style={styles.subjectText}>{subject}</Text>
|
||||
</View>
|
||||
<View style={styles.subjectMeta}>
|
||||
<Text style={styles.subjectDate}>{formatHeaderDate(email.receivedAt)}</Text>
|
||||
{email.size > 0 && (
|
||||
<Text style={styles.subjectSize}>{formatSize(email.size)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sender info */}
|
||||
<View style={styles.senderBlock}>
|
||||
<View style={styles.senderRow}>
|
||||
<SenderAvatar
|
||||
name={from?.name}
|
||||
email={from?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
/>
|
||||
<View style={styles.senderInfo}>
|
||||
<Text style={styles.senderName} numberOfLines={1}>
|
||||
{from?.name || from?.email || 'Unknown sender'}
|
||||
</Text>
|
||||
{from?.name && from?.email ? (
|
||||
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
|
||||
) : null}
|
||||
<Text style={styles.senderRecipients} numberOfLines={1}>
|
||||
<Text style={styles.senderRecipientsLabel}>to </Text>
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{attachmentPosition === 'beside-sender' && renderAttachments()}
|
||||
</View>
|
||||
|
||||
{/* Attachments chips (full-width below header) */}
|
||||
{attachmentPosition === 'below-header' && renderAttachments()}
|
||||
|
||||
{/* Calendar invitation (auto-detected .ics) */}
|
||||
<CalendarInvitationBanner email={email} />
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.bodyBlock}>
|
||||
<EmailBodyView email={email} senderEmail={from?.email} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Bottom action bar */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
|
||||
@@ -685,7 +608,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<MoreMenuSheet
|
||||
visible={moreMenuOpen}
|
||||
@@ -744,6 +667,206 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
interface EmailPaneProps {
|
||||
email: Email | null;
|
||||
c: ThemePalette;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
bottomBarHeight: number;
|
||||
attachmentPosition: 'beside-sender' | 'below-header';
|
||||
hideInlineImageAttachments: boolean;
|
||||
downloadingBlobId: string | null;
|
||||
onToggleStar: (email: Email) => void;
|
||||
onPressAttachment: (email: Email, blobId: string, name: string | undefined, type: string | undefined) => void;
|
||||
}
|
||||
|
||||
// One swipeable page: the scrollable subject / sender / attachments / body for a
|
||||
// single message. The pager keeps three of these mounted (prev, current, next)
|
||||
// so a swipe slides ready content into view. Each pane owns its own vertical
|
||||
// scroll position and "show all attachments" toggle, and renders directly from
|
||||
// the email passed to it — neighbours show real content, not a placeholder.
|
||||
function EmailPane({
|
||||
email, c, styles, bottomBarHeight, attachmentPosition,
|
||||
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment,
|
||||
}: EmailPaneProps) {
|
||||
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
|
||||
|
||||
// Detail not fetched yet (initial open, or a neighbour the prefetch hasn't
|
||||
// caught up with). Show a skeleton of the pane layout instead of a spinner
|
||||
// so the transition into real content doesn't jump.
|
||||
if (!email) {
|
||||
return <EmailPaneSkeleton styles={styles} />;
|
||||
}
|
||||
|
||||
const from = email.from?.[0];
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const subject = email.subject || '(no subject)';
|
||||
|
||||
const renderAttachments = () => {
|
||||
const all = email.attachments;
|
||||
if (!all || all.length === 0) return null;
|
||||
// hideInlineImageAttachments: drop chips for images already shown inline
|
||||
// in the body (any image attachment with a cid:). Non-image inline
|
||||
// attachments stay visible because the user wouldn't see them otherwise.
|
||||
const atts = hideInlineImageAttachments
|
||||
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
|
||||
: all;
|
||||
if (atts.length === 0) return null;
|
||||
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
|
||||
return (
|
||||
<View style={styles.attachmentsBlock}>
|
||||
<View style={styles.attachmentsRow}>
|
||||
{visible.map((att, idx) => {
|
||||
const isDownloading = downloadingBlobId === att.blobId;
|
||||
return (
|
||||
<Pressable
|
||||
key={att.blobId ?? idx}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentChip,
|
||||
pressed && styles.attachmentChipPressed,
|
||||
]}
|
||||
onPress={() => onPressAttachment(email, att.blobId, att.name, att.type)}
|
||||
disabled={!!downloadingBlobId}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Paperclip size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||
{att.name || 'attachment'}
|
||||
</Text>
|
||||
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{atts.length > 3 && (
|
||||
<Pressable
|
||||
onPress={() => setAttachmentsExpanded((v) => !v)}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentsToggle,
|
||||
pressed && styles.attachmentsTogglePressed,
|
||||
]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.attachmentsToggleText}>
|
||||
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
|
||||
>
|
||||
{/* Subject block */}
|
||||
<View style={styles.subjectBlock}>
|
||||
<View style={styles.subjectRow}>
|
||||
<View style={styles.subjectMain}>
|
||||
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.subjectStar}>
|
||||
<Star
|
||||
size={18}
|
||||
color={starred ? c.starred : c.textMuted}
|
||||
fill={starred ? c.starred : 'transparent'}
|
||||
/>
|
||||
</Pressable>
|
||||
<Text style={styles.subjectText}>{subject}</Text>
|
||||
</View>
|
||||
<View style={styles.subjectMeta}>
|
||||
<Text style={styles.subjectDate}>{formatHeaderDate(email.receivedAt)}</Text>
|
||||
{email.size > 0 && (
|
||||
<Text style={styles.subjectSize}>{formatSize(email.size)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sender info */}
|
||||
<View style={styles.senderBlock}>
|
||||
<View style={styles.senderRow}>
|
||||
<SenderAvatar
|
||||
name={from?.name}
|
||||
email={from?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
/>
|
||||
<View style={styles.senderInfo}>
|
||||
<Text style={styles.senderName} numberOfLines={1}>
|
||||
{from?.name || from?.email || 'Unknown sender'}
|
||||
</Text>
|
||||
{from?.name && from?.email ? (
|
||||
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
|
||||
) : null}
|
||||
<Text style={styles.senderRecipients} numberOfLines={1}>
|
||||
<Text style={styles.senderRecipientsLabel}>to </Text>
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{attachmentPosition === 'beside-sender' && renderAttachments()}
|
||||
</View>
|
||||
|
||||
{/* Attachments chips (full-width below header) */}
|
||||
{attachmentPosition === 'below-header' && renderAttachments()}
|
||||
|
||||
{/* Calendar invitation (auto-detected .ics) */}
|
||||
<CalendarInvitationBanner email={email} />
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.bodyBlock}>
|
||||
<EmailBodyView email={email} senderEmail={from?.email} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder mimicking the pane layout (subject, sender, body lines) shown
|
||||
// while a message's detail is loading. One pulsing opacity over static bones
|
||||
// keeps it cheap enough to also sit in the off-screen neighbour panes.
|
||||
function EmailPaneSkeleton({ styles }: { styles: ReturnType<typeof makeStyles> }) {
|
||||
const pulse = React.useRef(new Animated.Value(0.55)).current;
|
||||
React.useEffect(() => {
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 0.55, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [pulse]);
|
||||
|
||||
const bodyLineWidths = ['92%', '100%', '85%', '96%', '60%', '88%', '74%', '40%'] as const;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.scroll, { opacity: pulse }]}>
|
||||
<View style={styles.subjectBlock}>
|
||||
<View style={[styles.skeletonBone, { height: 20, width: '78%' }]} />
|
||||
<View style={[styles.skeletonBone, { height: 12, width: '36%', marginTop: spacing.sm }]} />
|
||||
</View>
|
||||
<View style={styles.senderBlock}>
|
||||
<View style={styles.senderRow}>
|
||||
<View style={styles.skeletonAvatar} />
|
||||
<View style={styles.senderInfo}>
|
||||
<View style={[styles.skeletonBone, { height: 14, width: '55%' }]} />
|
||||
<View style={[styles.skeletonBone, { height: 11, width: '70%', marginTop: 6 }]} />
|
||||
<View style={[styles.skeletonBone, { height: 11, width: '45%', marginTop: 6 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.skeletonBody}>
|
||||
{bodyLineWidths.map((w, i) => (
|
||||
<View key={i} style={[styles.skeletonBone, { height: 12, width: w }]} />
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -1065,7 +1188,9 @@ function makeStyles(c: ThemePalette) {
|
||||
padding: spacing.lg,
|
||||
},
|
||||
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||
slidePane: { flex: 1, backgroundColor: c.background },
|
||||
// Pager: a clipping viewport with a 3-screen-wide row of panes inside it.
|
||||
pagerViewport: { flex: 1, overflow: 'hidden', backgroundColor: c.background },
|
||||
pagerRow: { position: 'absolute', top: 0, bottom: 0, flexDirection: 'row' },
|
||||
scroll: { flex: 1, backgroundColor: c.background },
|
||||
|
||||
// Subject block
|
||||
@@ -1200,6 +1325,23 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
|
||||
// Loading skeleton
|
||||
skeletonBone: {
|
||||
backgroundColor: c.surfaceHover,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
skeletonAvatar: {
|
||||
width: componentSizes.avatarMd,
|
||||
height: componentSizes.avatarMd,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.surfaceHover,
|
||||
},
|
||||
skeletonBody: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
|
||||
// Bottom bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Constants from 'expo-constants';
|
||||
import { Eye, EyeOff, X } from 'lucide-react-native';
|
||||
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button, Input } from '../components';
|
||||
import { QrScanModal } from '../components/QrScanModal';
|
||||
import { parseQrLoginPayload } from '../lib/oauth';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
@@ -21,6 +23,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
@@ -29,6 +32,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [scannerVisible, setScannerVisible] = React.useState(false);
|
||||
|
||||
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
|
||||
const canHandoff = Boolean(serverUrl.trim());
|
||||
@@ -65,6 +69,34 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanned = async (data: string) => {
|
||||
setScannerVisible(false);
|
||||
const payload = parseQrLoginPayload(data);
|
||||
if (!payload) {
|
||||
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
if (payload.kind === 'connect') {
|
||||
// Server-bootstrap QR: fill the field and run the normal browser
|
||||
// handoff so the user still authenticates in the webmail.
|
||||
setServerUrl(payload.webmailUrl);
|
||||
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
|
||||
} else {
|
||||
// Cross-device pairing QR: redeem the one-time code for tokens, no
|
||||
// browser round-trip needed.
|
||||
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
|
||||
}
|
||||
const after = useAuthStore.getState().isAuthenticated;
|
||||
if ((after && !before) || (isAddMode && after)) {
|
||||
onLogin?.();
|
||||
}
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = React.useCallback(
|
||||
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
|
||||
if (useAuthStore.getState().error) {
|
||||
@@ -182,6 +214,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
>
|
||||
Sign in via webmail
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onPress={() => setScannerVisible(true)}
|
||||
disabled={isLoading}
|
||||
icon={<QrCode size={18} color={c.text} />}
|
||||
>
|
||||
Scan QR code
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -192,6 +233,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<QrScanModal
|
||||
visible={scannerVisible}
|
||||
onClose={() => setScannerVisible(false)}
|
||||
onScanned={(data) => {
|
||||
void handleScanned(data);
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-26
@@ -7,7 +7,7 @@ import { useContactsStore } from './contacts-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import { useFilterStore } from './filter-store';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
|
||||
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth';
|
||||
import {
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
@@ -41,6 +41,7 @@ export interface AuthState {
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
@@ -97,6 +98,43 @@ function refetchFeatureStores(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
|
||||
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
||||
// registers the account, and flips the store to connected. Throws on failure
|
||||
// so the caller can surface a flow-specific error.
|
||||
async function completeOAuthHandoff(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
||||
opts?: { addAccount?: boolean },
|
||||
): Promise<void> {
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(
|
||||
result.serverUrl,
|
||||
result.tokens,
|
||||
);
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
}
|
||||
|
||||
function applyConnectedState(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
session: JMAPSession,
|
||||
@@ -200,31 +238,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// token bundle. Bootstrap the JMAP session with Bearer auth and let
|
||||
// ensure/forceRefreshToken keep it alive going forward.
|
||||
try {
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(
|
||||
result.serverUrl,
|
||||
result.tokens,
|
||||
);
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
await completeOAuthHandoff(set, get, result, opts);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
@@ -237,6 +251,38 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginViaPairing: async (webmailUrl, code, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
let result;
|
||||
try {
|
||||
result = await redeemPairingCode(webmailUrl, code);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Pairing failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// redeemPairingCode only ever yields the OAuth flow, but guard anyway so a
|
||||
// future server change can't silently mis-route credentials here.
|
||||
if (result.flow !== 'oauth') {
|
||||
set({ isLoading: false, error: 'Unexpected pairing response' });
|
||||
throw new Error('Unexpected pairing response');
|
||||
}
|
||||
|
||||
try {
|
||||
await completeOAuthHandoff(set, get, result, opts);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
? 'Authentication rejected by server'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Pairing sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const accountStore = useAccountStore.getState();
|
||||
const currentId = get().activeAccountId;
|
||||
|
||||
Reference in New Issue
Block a user