mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.
Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
This commit is contained in:
@@ -151,6 +151,7 @@ export class JMAPClient {
|
||||
|
||||
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
|
||||
if (!this.credentials) return;
|
||||
if (this.credentials.accessToken === next.accessToken) return;
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: next.accessToken,
|
||||
|
||||
@@ -101,7 +101,10 @@ async function ensureFreshCredentials(
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
const current = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!current || current.accessToken !== updated.accessToken) {
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
}
|
||||
return updated;
|
||||
} catch {
|
||||
// Fall back to the existing (possibly expired) token; the request will
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Mail } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import type { Identity } from '../api/types';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
interface IdentitySheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
identities: Identity[];
|
||||
selectedIdentityId: string | null;
|
||||
onPick: (identity: Identity) => void;
|
||||
}
|
||||
|
||||
export function IdentitySheet({
|
||||
visible, onClose, identities, selectedIdentityId, onPick,
|
||||
}: IdentitySheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
const { t } = useLocaleStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView style={styles.scrollList}>
|
||||
{identities.map((identity) => {
|
||||
const isSelected = identity.id === selectedIdentityId;
|
||||
return (
|
||||
<Pressable
|
||||
key={identity.id}
|
||||
onPress={() => {
|
||||
onPick(identity);
|
||||
onClose();
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.identityRow,
|
||||
pressed && styles.identityRowPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.iconWrap}>
|
||||
<Mail size={16} color={isSelected ? c.primary : c.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.identityDetails}>
|
||||
{!!identity.name && (
|
||||
<Text style={styles.identityName} numberOfLines={1}>
|
||||
{identity.name}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
|
||||
{identity.email}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<View style={styles.checkWrap}>
|
||||
<Check size={16} color={c.primary} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
sheetOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetOverlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '60%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetHandleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
sheetHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: c.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
scrollList: {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
identityRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 52,
|
||||
},
|
||||
identityRowPressed: {
|
||||
backgroundColor: c.surfaceHover,
|
||||
},
|
||||
iconWrap: {
|
||||
marginRight: spacing.md,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
identityDetails: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
identityName: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
},
|
||||
identityEmail: {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
identityEmailOnly: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
marginTop: 0,
|
||||
},
|
||||
checkWrap: {
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -172,9 +172,9 @@ export function SwipeableRow({
|
||||
return;
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
|
||||
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, rightAction);
|
||||
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
|
||||
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
|
||||
} else {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
|
||||
@@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 50,
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -5,3 +5,5 @@ export { default as Badge } from './Badge';
|
||||
export { Card, SectionHeader, SettingItemRow } from './Card';
|
||||
export { default as RadioGroup } from './RadioGroup';
|
||||
export { default as Dialog } from './Dialog';
|
||||
export { IdentitySheet } from './IdentitySheet';
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockSecureFetch } = vi.hoisted(() => ({
|
||||
mockSecureFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../client-cert', () => ({
|
||||
secureFetch: mockSecureFetch,
|
||||
}));
|
||||
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../oauth';
|
||||
|
||||
describe('oauth token refresh deduplication', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should only send a single HTTP request when refreshOAuthAccessToken is called concurrently with the same refresh token', async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
accessToken: 'old-access',
|
||||
refreshToken: 'same-refresh-token',
|
||||
expiresAt: 123456,
|
||||
tokenEndpoint: 'https://auth.example.com/token',
|
||||
clientId: 'client-id-123',
|
||||
};
|
||||
|
||||
// Set up a mock response that takes a brief moment to resolve
|
||||
mockSecureFetch.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'rotated-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
// Fire two refreshes concurrently
|
||||
const [res1, res2] = await Promise.all([
|
||||
refreshOAuthAccessToken(tokens),
|
||||
refreshOAuthAccessToken(tokens),
|
||||
]);
|
||||
|
||||
// Check that secureFetch was called exactly once
|
||||
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify both calls received the exact same updated token bundle
|
||||
expect(res1.accessToken).toBe('new-access-token');
|
||||
expect(res1.refreshToken).toBe('rotated-refresh-token');
|
||||
expect(res2.accessToken).toBe('new-access-token');
|
||||
expect(res2.refreshToken).toBe('rotated-refresh-token');
|
||||
|
||||
// Fire another refresh after the first completes (now the map should be empty for this token)
|
||||
mockSecureFetch.mockClear();
|
||||
const res3 = await refreshOAuthAccessToken(tokens);
|
||||
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
|
||||
expect(res3.accessToken).toBe('new-access-token');
|
||||
});
|
||||
|
||||
it('should propagate errors to all waiters if the HTTP request fails', async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
accessToken: 'old-access',
|
||||
refreshToken: 'failing-refresh-token',
|
||||
expiresAt: 123456,
|
||||
tokenEndpoint: 'https://auth.example.com/token',
|
||||
clientId: 'client-id-123',
|
||||
};
|
||||
|
||||
mockSecureFetch.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
} as Response;
|
||||
});
|
||||
|
||||
// Fire two refreshes concurrently and check that both reject with the same error
|
||||
const promise1 = refreshOAuthAccessToken(tokens);
|
||||
const promise2 = refreshOAuthAccessToken(tokens);
|
||||
|
||||
await expect(promise1).rejects.toThrow('Token refresh failed: 400');
|
||||
await expect(promise2).rejects.toThrow('Token refresh failed: 400');
|
||||
|
||||
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -96,3 +96,36 @@ export function flattenVisible(
|
||||
walk(nodes);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findTrashMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
||||
const roleMatch = mailboxes.find((m) => m.role === 'trash');
|
||||
if (roleMatch) return roleMatch;
|
||||
|
||||
const names = ['trash', 'bin', 'deleted', 'deleted items', 'corbeille', 'messages supprimés', 'éléments supprimés', 'supprimé', 'supprimés'];
|
||||
return mailboxes.find((m) => {
|
||||
const lower = m.name.toLowerCase();
|
||||
return names.includes(lower) || names.some((n) => lower.includes(n));
|
||||
});
|
||||
}
|
||||
|
||||
export function findArchiveMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
||||
const roleMatch = mailboxes.find((m) => m.role === 'archive');
|
||||
if (roleMatch) return roleMatch;
|
||||
|
||||
const names = ['archive', 'archives', 'archived'];
|
||||
return mailboxes.find((m) => {
|
||||
const lower = m.name.toLowerCase();
|
||||
return names.includes(lower) || names.some((n) => lower.includes(n));
|
||||
});
|
||||
}
|
||||
|
||||
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
||||
const roleMatch = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
|
||||
if (roleMatch) return roleMatch;
|
||||
|
||||
const names = ['junk', 'spam', 'indésirables', 'indésirable', 'courrier indésirable'];
|
||||
return mailboxes.find((m) => {
|
||||
const lower = m.name.toLowerCase();
|
||||
return names.includes(lower) || names.some((n) => lower.includes(n));
|
||||
});
|
||||
}
|
||||
|
||||
+47
-30
@@ -207,6 +207,8 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
|
||||
};
|
||||
}
|
||||
|
||||
const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
|
||||
|
||||
// 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.
|
||||
@@ -214,37 +216,52 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
||||
if (!tokens.refreshToken) {
|
||||
throw new HandoffError('No refresh token available');
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken,
|
||||
client_id: tokens.clientId,
|
||||
});
|
||||
const response = await secureFetch(tokens.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HandoffError(`Token refresh failed: ${response.status}`);
|
||||
|
||||
const cacheKey = tokens.refreshToken;
|
||||
let promise = activeRefreshes.get(cacheKey);
|
||||
|
||||
if (!promise) {
|
||||
promise = (async () => {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken!,
|
||||
client_id: tokens.clientId,
|
||||
});
|
||||
const response = await secureFetch(tokens.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HandoffError(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken!,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
} finally {
|
||||
activeRefreshes.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
activeRefreshes.set(cacheKey, promise);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
+104
-31
@@ -17,7 +17,7 @@ import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { File as FsFile } from 'expo-file-system';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button } from '../components';
|
||||
import { Button, IdentitySheet } from '../components';
|
||||
import RichTextEditor, {
|
||||
type RichTextEditorHandle,
|
||||
type RichTextSelectionState,
|
||||
@@ -26,6 +26,7 @@ import { useEmailStore } from '../stores/email-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { isGroup } from '../lib/contact-utils';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -69,7 +70,16 @@ function parseRecipients(input: string): Recipient[] {
|
||||
.split(/[,;\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((email) => ({ name: '', email }));
|
||||
.map((s) => {
|
||||
const match = s.match(/^(.*?)\s*<([^\s@]+@[^\s@]+\.[^\s@]+)>$/);
|
||||
if (match) {
|
||||
return {
|
||||
name: match[1].replace(/^["']|["']$/g, '').trim(),
|
||||
email: match[2].trim(),
|
||||
};
|
||||
}
|
||||
return { name: '', email: s };
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
@@ -113,10 +123,11 @@ function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove
|
||||
}
|
||||
|
||||
function SuggestionList({
|
||||
suggestions, onPick,
|
||||
suggestions, onPick, onPressIn,
|
||||
}: {
|
||||
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
|
||||
onPick: (s: { name: string; email: string }) => void;
|
||||
onPressIn?: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -127,6 +138,7 @@ function SuggestionList({
|
||||
return (
|
||||
<Pressable
|
||||
key={`${s.contact.id}-${s.email}-${i}`}
|
||||
onPressIn={onPressIn}
|
||||
onPress={() => onPick(s)}
|
||||
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
|
||||
>
|
||||
@@ -260,6 +272,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const [identityError, setIdentityError] = React.useState<string | null>(null);
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
|
||||
const [identitySheetOpen, setIdentitySheetOpen] = React.useState(false);
|
||||
const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
|
||||
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
|
||||
// can only show one field at a time, so we walk date → time.
|
||||
@@ -343,6 +356,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
});
|
||||
|
||||
const editorRef = React.useRef<RichTextEditorHandle>(null);
|
||||
const isPickingSuggestion = React.useRef(false);
|
||||
// Track inline-image placeholders that haven't yet been rewritten to cid:
|
||||
// until send time. Maps cid → blobId/type/name/size.
|
||||
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
|
||||
@@ -375,6 +389,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
}, [suggestionQuery, individuals, alreadySelected]);
|
||||
|
||||
const pickSuggestion = (s: { name: string; email: string }) => {
|
||||
isPickingSuggestion.current = true;
|
||||
const recipient: Recipient = { name: s.name, email: s.email };
|
||||
if (activeField === 'cc') {
|
||||
setCcRecipients((prev) => [...prev, recipient]);
|
||||
@@ -405,34 +420,35 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// original message (matches webmail's reply-identity behaviour).
|
||||
React.useEffect(() => {
|
||||
if (selectedIdentityId || identities.length === 0) return;
|
||||
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
|
||||
const defaultIdentity = identities.find(
|
||||
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
|
||||
) ?? identities[0];
|
||||
|
||||
if (autoSelectReplyIdentity && replyTo) {
|
||||
const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])];
|
||||
const candidateAddrs = new Set(
|
||||
candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[],
|
||||
);
|
||||
const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase()));
|
||||
setSelectedIdentityId((matched ?? identities[0]).id);
|
||||
setSelectedIdentityId((matched ?? defaultIdentity).id);
|
||||
} else {
|
||||
setSelectedIdentityId(identities[0].id);
|
||||
setSelectedIdentityId(defaultIdentity.id);
|
||||
}
|
||||
}, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]);
|
||||
|
||||
const primaryIdentity =
|
||||
identities.find((i) => i.id === selectedIdentityId) ?? identities[0];
|
||||
const primaryIdentity = React.useMemo(() => {
|
||||
if (identities.length === 0) return null;
|
||||
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
|
||||
const defaultIdentity = identities.find(
|
||||
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
|
||||
) ?? identities[0];
|
||||
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
|
||||
}, [identities, selectedIdentityId]);
|
||||
|
||||
const openIdentityPicker = () => {
|
||||
if (identities.length <= 1) return;
|
||||
Alert.alert(
|
||||
t('email_composer.from', 'From'),
|
||||
undefined,
|
||||
[
|
||||
...identities.map((i) => ({
|
||||
text: i.name ? `${i.name} <${i.email}>` : i.email,
|
||||
onPress: () => setSelectedIdentityId(i.id),
|
||||
})),
|
||||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const },
|
||||
],
|
||||
);
|
||||
setIdentitySheetOpen(true);
|
||||
};
|
||||
|
||||
const commitTyped = () => {
|
||||
@@ -448,11 +464,33 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const extraTo = parseRecipients(toInput);
|
||||
const extraCc = parseRecipients(ccInput);
|
||||
if (extraTo.length) {
|
||||
setToRecipients((prev) => [...prev, ...extraTo]);
|
||||
setToRecipients((prev) => {
|
||||
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
|
||||
const unique: Recipient[] = [];
|
||||
for (const r of extraTo) {
|
||||
const emailLower = r.email.toLowerCase();
|
||||
if (!existing.has(emailLower)) {
|
||||
existing.add(emailLower);
|
||||
unique.push(r);
|
||||
}
|
||||
}
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
setToInput('');
|
||||
}
|
||||
if (extraCc.length) {
|
||||
setCcRecipients((prev) => [...prev, ...extraCc]);
|
||||
setCcRecipients((prev) => {
|
||||
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
|
||||
const unique: Recipient[] = [];
|
||||
for (const r of extraCc) {
|
||||
const emailLower = r.email.toLowerCase();
|
||||
if (!existing.has(emailLower)) {
|
||||
existing.add(emailLower);
|
||||
unique.push(r);
|
||||
}
|
||||
}
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
setCcInput('');
|
||||
}
|
||||
};
|
||||
@@ -976,7 +1014,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardShouldPersistTaps="always"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.fieldRow}>
|
||||
@@ -993,7 +1031,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldRow}>
|
||||
<View style={[styles.fieldRow, { zIndex: 5 }]}>
|
||||
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
|
||||
<View style={styles.recipientField}>
|
||||
{toRecipients.map((r, i) => (
|
||||
@@ -1011,8 +1049,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onChangeText={setToInput}
|
||||
onFocus={() => setActiveField('to')}
|
||||
onBlur={() => {
|
||||
addTyped();
|
||||
setTimeout(() => setActiveField((f) => (f === 'to' ? null : f)), 200);
|
||||
setTimeout(() => {
|
||||
if (!isPickingSuggestion.current) {
|
||||
addTyped();
|
||||
}
|
||||
setActiveField((f) => (f === 'to' ? null : f));
|
||||
isPickingSuggestion.current = false;
|
||||
}, 200);
|
||||
}}
|
||||
onSubmitEditing={addTyped}
|
||||
keyboardType="email-address"
|
||||
@@ -1021,7 +1064,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
/>
|
||||
</View>
|
||||
{activeField === 'to' && suggestions.length > 0 && (
|
||||
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
|
||||
<SuggestionList
|
||||
suggestions={suggestions}
|
||||
onPick={pickSuggestion}
|
||||
onPressIn={() => {
|
||||
isPickingSuggestion.current = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!ccVisible && (
|
||||
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
|
||||
@@ -1031,7 +1080,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
|
||||
{ccVisible && (
|
||||
<View style={styles.fieldRow}>
|
||||
<View style={[styles.fieldRow, { zIndex: 4 }]}>
|
||||
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
|
||||
<View style={styles.recipientField}>
|
||||
{ccRecipients.map((r, i) => (
|
||||
@@ -1049,8 +1098,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onChangeText={setCcInput}
|
||||
onFocus={() => setActiveField('cc')}
|
||||
onBlur={() => {
|
||||
addTyped();
|
||||
setTimeout(() => setActiveField((f) => (f === 'cc' ? null : f)), 200);
|
||||
setTimeout(() => {
|
||||
if (!isPickingSuggestion.current) {
|
||||
addTyped();
|
||||
}
|
||||
setActiveField((f) => (f === 'cc' ? null : f));
|
||||
isPickingSuggestion.current = false;
|
||||
}, 200);
|
||||
}}
|
||||
onSubmitEditing={addTyped}
|
||||
keyboardType="email-address"
|
||||
@@ -1059,7 +1113,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
/>
|
||||
</View>
|
||||
{activeField === 'cc' && suggestions.length > 0 && (
|
||||
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
|
||||
<SuggestionList
|
||||
suggestions={suggestions}
|
||||
onPick={pickSuggestion}
|
||||
onPressIn={() => {
|
||||
isPickingSuggestion.current = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
@@ -1293,6 +1353,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onChange={onCustomPickerChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IdentitySheet
|
||||
visible={identitySheetOpen}
|
||||
onClose={() => setIdentitySheetOpen(false)}
|
||||
identities={identities}
|
||||
selectedIdentityId={selectedIdentityId}
|
||||
onPick={(identity) => setSelectedIdentityId(identity.id)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1332,6 +1400,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
gap: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
fieldLabel: {
|
||||
...typography.body,
|
||||
@@ -1374,12 +1443,16 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingVertical: 6,
|
||||
},
|
||||
suggestionBox: {
|
||||
marginTop: spacing.xs,
|
||||
marginLeft: 60,
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: spacing.lg + 56 + spacing.md,
|
||||
right: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.borderLight,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.card,
|
||||
zIndex: 20,
|
||||
elevation: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
suggestionRow: {
|
||||
|
||||
@@ -24,8 +24,8 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import { useKeywordsStore } from '../stores/keywords-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import { findTrashMailbox, findArchiveMailbox, findJunkMailbox } from '../lib/mailbox-tree';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
function getSenderName(email: Email): string {
|
||||
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
|
||||
}
|
||||
@@ -164,6 +164,7 @@ interface EmailListScreenProps {
|
||||
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { t } = useLocaleStore();
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
|
||||
const emails = useEmailStore((s) => s.emails);
|
||||
@@ -232,15 +233,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}, [emails, disableThreading]);
|
||||
|
||||
const archiveMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
|
||||
() => findArchiveMailbox(mailboxes)?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const trashMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
|
||||
() => findTrashMailbox(mailboxes)?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const junkMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
|
||||
() => findJunkMailbox(mailboxes)?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
|
||||
@@ -336,16 +337,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
case 'archive':
|
||||
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||
void archiveEmailAction(id);
|
||||
} else if (!archiveMailboxId) {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
t('email_list.no_archive_folder', 'Could not find an Archive folder on the server.'),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (trashMailboxId) {
|
||||
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'spam':
|
||||
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
|
||||
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
|
||||
} else if (!junkMailboxId) {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.'),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'read':
|
||||
@@ -365,7 +381,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}, [
|
||||
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
|
||||
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
|
||||
markRead, markUnread, toggleStar, togglePin,
|
||||
markRead, markUnread, toggleStar, togglePin, t,
|
||||
]);
|
||||
|
||||
const renderEmailRow = React.useCallback(
|
||||
@@ -433,7 +449,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const trash = mailboxes.find((m) => m.role === 'trash');
|
||||
const trash = findTrashMailbox(mailboxes);
|
||||
if (!trash || !currentMailboxId) {
|
||||
clearSelection();
|
||||
return;
|
||||
|
||||
@@ -29,6 +29,8 @@ import { setEmailKeywords } from '../api/email';
|
||||
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { findTrashMailbox } from '../lib/mailbox-tree';
|
||||
import type { Email, Mailbox } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
@@ -65,6 +67,7 @@ function plainTextBody(email: Email): string {
|
||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { t } = useLocaleStore();
|
||||
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
|
||||
@@ -299,8 +302,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
|
||||
const onDelete = () => {
|
||||
if (!email || !currentMailboxId) return;
|
||||
const trash = mailboxes.find((m) => m.role === 'trash');
|
||||
if (!trash) return;
|
||||
const trash = findTrashMailbox(mailboxes);
|
||||
if (!trash) {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void deleteEmail(email.id, trash.id, currentMailboxId);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user