Compare commits

..
5 Commits
15 changed files with 967 additions and 82 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.41
0.1.43
+7
View File
@@ -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
View File
@@ -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,
},
+23 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.41",
"version": "0.1.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.41",
"version": "0.1.43",
"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
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.41",
"version": "0.1.43",
"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",
+161 -5
View File
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-nati
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
@@ -45,29 +45,164 @@ function extractTextBody(email: Email): string | null {
return null;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children.
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
el.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
const HEIGHT_REPORTER = `
(function () {
// One-time: wrap body contents in a block-level div we can measure reliably.
// body.scrollHeight on Android WebView is pinned to the container viewport
// height, so we can't trust it. A dedicated wrapper gives a deterministic
// bounding box.
var wrapperEl = null;
function ensureWrapper() {
var b = document.body;
if (!b) return null;
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
var wrap = document.getElementById('__rn_email_body_wrap__');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = '__rn_email_body_wrap__';
wrap.style.display = 'block';
wrap.style.width = '100%';
wrap.style.transformOrigin = 'top left';
while (b.firstChild) wrap.appendChild(b.firstChild);
b.appendChild(wrap);
}
wrapperEl = wrap;
return wrap;
}
// The width available for the email content (body content-box).
function availWidth() {
var b = document.body;
var cs = window.getComputedStyle(b);
var padL = parseFloat(cs.paddingLeft) || 0;
var padR = parseFloat(cs.paddingRight) || 0;
return b.clientWidth - padL - padR;
}
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
// a phone viewport and force horizontal scrolling. Lay the content out at its
// natural width, then scale the whole block down so its widest element fits
// the screen — the way native mail clients do. No sideways scrolling.
function fitToWidth(w) {
if (!w) return;
var avail = availWidth();
if (avail <= 0) return;
// Reset to full width so wrapping content reports its true overflow extent
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
var content = w.scrollWidth;
if (content > avail + 1) {
var scale = avail / content;
w.style.width = content + 'px';
w.style.transform = 'scale(' + scale + ')';
} else {
w.style.transform = 'none';
}
}
function measure() {
var w = ensureWrapper();
if (!w) return 0;
fitToWidth(w);
// getBoundingClientRect reflects the applied transform, so r.height is the
// already-scaled visual height of the content.
var r = w.getBoundingClientRect();
var b = document.body;
var cs = window.getComputedStyle(b);
@@ -117,7 +252,16 @@ const HEIGHT_REPORTER = `
if (document.body) ro.observe(document.body);
} catch (e) {}
try {
var mo = new MutationObserver(report);
var mo = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
// otherwise scaling would re-trigger a measure and loop indefinitely.
var rec = records[i];
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
report();
return;
}
});
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
} catch (e) {}
try {
@@ -273,6 +417,16 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
};
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
// Inversion is applied for HTML bodies in dark mode unless the email ships its
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
// stylesheet backgrounds) so it runs before the height reporter measures.
const applyInversion =
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
const injectedJs = React.useMemo(
() => (applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER),
[applyInversion],
);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
if (Number.isNaN(parsed) || parsed <= 0) return;
@@ -316,13 +470,15 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
// Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout.
// `injectedJavaScript` runs too early to see the final layout. The
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
// again here is a no-op once the body has been processed.
setTimeout(() => {
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
webviewRef.current?.injectJavaScript(injectedJs);
}, 50);
}}
scrollEnabled={false}
+124
View File
@@ -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' },
});
}
+81 -3
View File
@@ -1,10 +1,18 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, RadioGroup } from './settings-section';
import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native';
import { SettingsSection, RadioGroup, Select } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
ALL_QUICK_ACTIONS,
type SwipeAction,
type SwipeMode,
type QuickAction,
} from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -22,6 +30,24 @@ const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
const QUICK_ACTION_LABELS: Record<QuickAction, string> = {
reply: 'Reply',
replyAll: 'Reply All',
forward: 'Forward',
delete: 'Delete',
archive: 'Archive',
markUnread: 'Mark Read/Unread',
star: 'Star',
move: 'Move to folder',
spam: 'Spam',
tag: 'Tag',
};
const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({
value,
label: QUICK_ACTION_LABELS[value],
}));
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -31,11 +57,27 @@ export function LayoutSettings() {
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const update = useSettingsStore((s) => s.updateSetting);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
// Pick an action for a bar slot. If the chosen action already occupies
// another slot, swap the two so the three slots stay unique.
const setQuickActionSlot = (index: number, value: QuickAction) => {
const next = [...bottomActions];
const existing = next.indexOf(value);
if (existing !== -1 && existing !== index) {
next[existing] = next[index];
}
next[index] = value;
update('bottomQuickActions', next);
};
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
@@ -81,6 +123,34 @@ export function LayoutSettings() {
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<MoveVertical size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Reader quick actions</Text>
</View>
<Text style={styles.rowDescription}>
The three buttons shown between Prev/Next at the bottom of an open
email. Defaults to Reply, Reply All and Forward.
</Text>
{bottomActions.map((action, index) => (
<View key={index} style={styles.slotRow}>
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
<Select
value={action}
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
options={QUICK_ACTION_OPTIONS}
/>
</View>
))}
{relocated.length > 0 && (
<Text style={styles.rowDescription}>
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
you can still use {relocated.length === 1 ? 'it' : 'them'}.
</Text>
)}
</View>
</SettingsSection>
);
}
@@ -90,5 +160,13 @@ function makeStyles(c: ThemePalette) {
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: c.text },
rowDescription: { ...typography.caption, color: c.mutedForeground },
slotRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
marginTop: spacing.xs,
},
slotLabel: { ...typography.body, color: c.text },
});
}
Binary file not shown.
+84
View File
@@ -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.
+2 -2
View File
@@ -593,7 +593,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={c.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Pressable
onPress={() => { void handleImport(); }}
@@ -1075,7 +1075,7 @@ function makeStyles(c: ThemePalette) {
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, // text-lg font-semibold
headerLogo: {
width: 28,
height: 28,
+292 -39
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert,
Modal, useWindowDimensions, Animated, Easing, Alert, PanResponder,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -18,7 +18,12 @@ import { CalendarInvitationBanner } from '../components/email/CalendarInvitation
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
type QuickAction,
} from '../stores/settings-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
@@ -54,7 +59,12 @@ function plainTextBody(email: Email): string {
export default function EmailThreadScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { emailId, subject: subjectParam, jmapAccountId } = route.params;
const { jmapAccountId } = route.params;
// The displayed email is tracked in local state (not a route param) so that
// 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);
@@ -66,19 +76,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
const currentIndex = emails.findIndex((e) => e.id === emailId);
const currentIndex = emails.findIndex((e) => e.id === activeEmailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject ?? '',
});
};
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
// True only while a Prev/Next/swipe transition is waiting on an
// not-yet-prefetched neighbour. Drives a lightweight overlay spinner so the
// brief gap shows feedback instead of a blank pane — without unmounting the
// (still-animating) slide pane the way the full-screen `loading` state would.
const [switching, setSwitching] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
@@ -86,10 +94,123 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
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.
const detailCache = React.useRef(new Map<string, Email>()).current;
// --- 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
// neighbours / dimensions without being recreated every render.
const translateX = React.useRef(new Animated.Value(0)).current;
const scrollRef = React.useRef<ScrollView>(null);
const animatingRef = React.useRef(false);
const prevEmailRef = React.useRef(prevEmail);
const nextEmailRef = React.useRef(nextEmail);
const windowWidthRef = React.useRef(windowWidth);
prevEmailRef.current = prevEmail;
nextEmailRef.current = nextEmail;
windowWidthRef.current = windowWidth;
const switchEmail = React.useCallback((direction: 'prev' | 'next') => {
const target = direction === 'next' ? nextEmailRef.current : prevEmailRef.current;
if (!target || animatingRef.current) return;
animatingRef.current = true;
const w = windowWidthRef.current;
const out = direction === 'next' ? -w : w;
// Make sure the target's detail is in the cache *before* we swap it into
// view. That way the load effect takes its no-spinner cached path and the
// full-screen `loading` state never fires — which is what unmounted the
// slide pane mid-animation and produced the blank-then-jump flash.
// Neighbours are usually prefetched, so this resolves immediately.
const ready: Promise<unknown> = detailCache.has(target.id)
? Promise.resolve()
: getEmailDetail(target.id, jmapAccountId)
.then((e) => { detailCache.set(target.id, e); })
.catch(() => { /* fall back to the load effect's own fetch */ });
// Swap content + slide the new message in from the opposite edge.
const swapAndSlideIn = () => {
setSwitching(false);
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; });
};
// Slide the current message the rest of the way out, then swap once the
// target is ready. If it isn't cached yet, show the overlay spinner during
// the (usually brief) wait rather than leaving an empty pane on screen.
Animated.timing(translateX, {
toValue: out, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true,
}).start(() => {
if (detailCache.has(target.id)) {
swapAndSlideIn();
} else {
setSwitching(true);
void ready.then(swapAndSlideIn);
}
});
}, [translateX, detailCache, getEmailDetail, jmapAccountId]);
const switchEmailRef = React.useRef(switchEmail);
switchEmailRef.current = switchEmail;
const panResponder = React.useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, g) => {
if (animatingRef.current) return false;
if (Math.abs(g.dx) < 10) return false;
// Only claim clearly-horizontal drags so the body's vertical scroll
// (and link taps) keep working.
return Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
},
onPanResponderMove: (_, g) => {
let dx = g.dx;
// Rubber-band when there's no neighbour to reach in that direction.
if ((dx > 0 && !prevEmailRef.current) || (dx < 0 && !nextEmailRef.current)) {
dx *= 0.25;
}
translateX.setValue(dx);
},
onPanResponderRelease: (_, g) => {
const w = windowWidthRef.current;
const threshold = Math.min(w * 0.28, 120);
const fast = Math.abs(g.vx) > 0.5;
if ((g.dx <= -threshold || (fast && g.vx < 0)) && nextEmailRef.current) {
switchEmailRef.current('next');
} 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();
}
},
onPanResponderTerminate: () => {
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
},
onPanResponderTerminationRequest: () => true,
}),
).current;
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const bottomActions = React.useMemo(
() => normalizeBottomQuickActions(bottomQuickActionsRaw),
[bottomQuickActionsRaw],
);
// Any reply-family action the user pulled out of the bottom bar is surfaced
// in the top toolbar so it stays reachable.
const relocatedActions = React.useMemo(
() => REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)),
[bottomActions],
);
const onPressAttachment = React.useCallback(
async (blobId: string, name: string | undefined, type: string | undefined) => {
@@ -175,24 +296,37 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => {
let cancelled = false;
let readTimer: ReturnType<typeof setTimeout> | null = null;
setLoading(true);
setError(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);
}
void (async () => {
try {
const fetched = await getEmailDetail(emailId, jmapAccountId);
const fetched = await getEmailDetail(activeEmailId, jmapAccountId);
if (cancelled) return;
detailCache.set(activeEmailId, fetched);
setEmail(fetched);
if (fetched && !fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(emailId, jmapAccountId);
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
}, markAsReadDelay);
} else {
void markRead(emailId, jmapAccountId);
void markRead(activeEmailId, jmapAccountId);
}
}
} catch (e) {
if (!cancelled) {
// Only surface the error if we have nothing to show; a cached copy is
// better than an error screen when the refresh fails.
if (!cancelled && !detailCache.has(activeEmailId)) {
setError(e instanceof Error ? e.message : 'Failed to load email');
}
} finally {
@@ -203,7 +337,19 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [emailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay]);
}, [activeEmailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay, detailCache]);
// Prefetch the adjacent messages so a swipe / Prev-Next lands on ready
// content instead of a spinner. Best-effort and silent.
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))
.catch(() => { /* ignore — prefetch is best-effort */ });
}
}
}, [prevEmail, nextEmail, getEmailDetail, jmapAccountId, detailCache]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -223,7 +369,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const updateLocalKeywords = (next: Record<string, boolean>) => {
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
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;
});
};
const onToggleKeyword = (token: string) => {
@@ -312,7 +464,79 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
});
};
const subject = email?.subject ?? subjectParam ?? '(no subject)';
// Registry of every action that can live in the bottom quick-action bar (or
// be relocated to the top toolbar). `icon` is a factory so the same action
// can render at the toolbar (18px) and bottom-bar (20px) sizes.
const quickActionRegistry: Record<
QuickAction,
{ label: string; icon: (size: number, color: string) => React.ReactNode; onPress: () => void; available: boolean }
> = {
reply: {
label: 'Reply',
icon: (s, col) => <Reply size={s} color={col} />,
onPress: () => navigateCompose('reply'),
available: true,
},
replyAll: {
label: 'Reply All',
icon: (s, col) => <ReplyAll size={s} color={col} />,
onPress: () => navigateCompose('replyAll'),
available: true,
},
forward: {
label: 'Forward',
icon: (s, col) => <Forward size={s} color={col} />,
onPress: () => navigateCompose('forward'),
available: true,
},
delete: {
label: 'Delete',
icon: (s, col) => <Trash2 size={s} color={col} />,
onPress: onDelete,
available: true,
},
archive: {
label: 'Archive',
icon: (s, col) => <Archive size={s} color={col} />,
onPress: onArchive,
available: !!archiveMailbox && currentMailboxId !== archiveMailbox?.id,
},
markUnread: {
label: unread ? 'Read' : 'Unread',
icon: (s, col) => (unread ? <MailOpen size={s} color={col} /> : <Mail size={s} color={col} />),
onPress: onToggleUnread,
available: true,
},
star: {
label: starred ? 'Unstar' : 'Star',
icon: (s, col) => (
<Star size={s} color={starred ? c.starred : col} fill={starred ? c.starred : 'transparent'} />
),
onPress: onToggleStar,
available: true,
},
move: {
label: 'Move',
icon: (s, col) => <FolderInput size={s} color={col} />,
onPress: () => setMoveMenuOpen(true),
available: mailboxes.length > 0,
},
spam: {
label: isInJunk ? 'Not spam' : 'Spam',
icon: (s, col) =>
isInJunk ? <ShieldCheck size={s} color={c.success} /> : <ShieldAlert size={s} color={col} />,
onPress: onToggleSpam,
available: !!junkMailbox || isInJunk,
},
tag: {
label: 'Tag',
icon: (s, col) => <Tag size={s} color={col} />,
onPress: () => setTagMenuOpen(true),
available: keywordDefs.length > 0,
},
};
const subject = email?.subject ?? activeSubject ?? '(no subject)';
const from = email?.from?.[0];
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
@@ -329,6 +553,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ArrowLeft size={22} color={c.text} />
</Pressable>
<View style={styles.toolbarActions}>
{relocatedActions.map((id) => {
const def = quickActionRegistry[id];
return (
<ToolbarButton
key={id}
icon={def.icon(18, c.textSecondary)}
label={def.label}
onPress={def.onPress}
/>
);
})}
<ToolbarButton
icon={<Trash2 size={18} color={c.textSecondary} />}
label="Delete"
@@ -373,17 +608,22 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</View>
</View>
{loading ? (
{loading && !email ? (
<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 }}
>
@@ -444,34 +684,40 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<EmailBodyView email={email} senderEmail={from?.email} />
</View>
</ScrollView>
</Animated.View>
{/* Transient spinner while a switch waits on an un-prefetched
neighbour — sits above the (slid-out) pane, not in place of it. */}
{switching && (
<View style={styles.switchOverlay} pointerEvents="none">
<ActivityIndicator color={c.primary} />
</View>
)}
{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label="Prev"
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
onPress={prevEmail ? () => switchEmail('prev') : undefined}
disabled={!prevEmail}
/>
<BottomBarButton
icon={<Reply size={20} color={c.textSecondary} />}
label="Reply"
onPress={() => navigateCompose('reply')}
/>
<BottomBarButton
icon={<ReplyAll size={20} color={c.textSecondary} />}
label="Reply All"
onPress={() => navigateCompose('replyAll')}
/>
<BottomBarButton
icon={<Forward size={20} color={c.textSecondary} />}
label="Forward"
onPress={() => navigateCompose('forward')}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label="Next"
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
onPress={nextEmail ? () => switchEmail('next') : undefined}
disabled={!nextEmail}
/>
</View>
@@ -856,6 +1102,13 @@ function makeStyles(c: ThemePalette) {
padding: spacing.lg,
},
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
switchOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.background,
},
slidePane: { flex: 1, backgroundColor: c.background },
scroll: { flex: 1, backgroundColor: c.background },
// Subject block
+51 -2
View File
@@ -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
View File
@@ -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;
+55
View File
@@ -21,6 +21,55 @@ export type SwipeAction =
| 'pin'
| 'move';
export type SwipeMode = 'instant' | 'reveal';
// Actions that can be placed in the email reader's bottom quick-action bar.
// The first three are the reply family (the default bar); any reply-family
// action the user removes from the bar is relocated to the top toolbar so it
// stays reachable.
export type QuickAction =
| 'reply'
| 'replyAll'
| 'forward'
| 'delete'
| 'archive'
| 'markUnread'
| 'star'
| 'move'
| 'spam'
| 'tag';
export const REPLY_QUICK_ACTIONS: QuickAction[] = ['reply', 'replyAll', 'forward'];
export const ALL_QUICK_ACTIONS: QuickAction[] = [
'reply',
'replyAll',
'forward',
'delete',
'archive',
'markUnread',
'star',
'move',
'spam',
'tag',
];
// The reader bottom bar always shows exactly three quick actions (between the
// prev/next nav buttons). Coerce any persisted value into three unique, valid
// ids, backfilling from the reply-family default when entries are missing.
export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
const out: QuickAction[] = [];
if (Array.isArray(value)) {
for (const a of value) {
if (ALL_QUICK_ACTIONS.includes(a as QuickAction) && !out.includes(a as QuickAction)) {
out.push(a as QuickAction);
}
}
}
for (const d of REPLY_QUICK_ACTIONS) {
if (out.length >= 3) break;
if (!out.includes(d)) out.push(d);
}
return out.slice(0, 3);
}
export type ArchiveMode = 'single' | 'year' | 'month';
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
export type FirstDayOfWeek = 0 | 1;
@@ -103,6 +152,10 @@ interface PersistedSettings {
swipeRightAction: SwipeAction;
swipeMode: SwipeMode;
// Email reader's bottom quick-action bar (3 slots). Defaults to the reply
// family; reply-family actions removed from here move to the top toolbar.
bottomQuickActions: QuickAction[];
// Archive
archiveMode: ArchiveMode;
@@ -210,6 +263,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
swipeRightAction: 'read',
swipeMode: 'instant',
bottomQuickActions: ['reply', 'replyAll', 'forward'],
archiveMode: 'single',
calendarDefaultView: 'month',