mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42f18a78b2 | ||
|
|
406d2c208a | ||
|
|
6f712bf43f | ||
|
|
844cb703c8 | ||
|
|
11e5963149 | ||
|
|
07438e010e | ||
|
|
3e33efb71f | ||
|
|
376ee3d678 | ||
|
|
6f19cdbd17 | ||
|
|
6985a7d5f6 | ||
|
|
8231a55acb | ||
|
|
1b0740eab4 | ||
|
|
c5e509274d | ||
|
|
1bf5920b04 | ||
|
|
2547c6f199 | ||
|
|
69e27a386c | ||
|
|
7390096567 | ||
|
|
58c642f27b | ||
|
|
b1d1e84666 | ||
|
|
55484453a1 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.54",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.54",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.54",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
const MIN_HEIGHT = 220;
|
||||
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
||||
|
||||
export type RichTextCommand =
|
||||
| 'bold'
|
||||
@@ -66,273 +63,6 @@ interface Props {
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||
// user can't accidentally (or via a malicious paste) ship a
|
||||
// javascript:/data: link to their recipient.
|
||||
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
|
||||
var trimmed = (url || '').trim();
|
||||
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
|
||||
ref,
|
||||
@@ -340,7 +70,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [height, setHeight] = React.useState(MIN_HEIGHT);
|
||||
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
@@ -400,7 +130,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
break;
|
||||
case 'height': {
|
||||
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
|
||||
@@ -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,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildEditorHtml } from '../editor-html';
|
||||
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
||||
|
||||
function extractScript(html: string): string {
|
||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
expect(m, 'page must contain an inline <script>').toBeTruthy();
|
||||
return m![1];
|
||||
}
|
||||
|
||||
describe('buildEditorHtml', () => {
|
||||
const base = { initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS };
|
||||
|
||||
// Regression for issue #11: backslash escapes inside the template literal
|
||||
// are cooked away (`\/` → `/`), which once emitted an invalid regex. That
|
||||
// SyntaxError killed the entire inline script, so the editor never posted
|
||||
// `change` messages and Send stayed disabled on Android/iOS.
|
||||
it('emits an inline script that parses as valid JavaScript', () => {
|
||||
for (const c of [LIGHT_COLORS, DARK_COLORS]) {
|
||||
const script = extractScript(buildEditorHtml({ ...base, c }));
|
||||
// new Function() parses without executing (script needs a DOM to run).
|
||||
expect(() => new Function(script)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('emits no cooked-away backslashes in the script', () => {
|
||||
// The page script is written escape-free on purpose; a backslash-free
|
||||
// source cannot silently lose escapes to template-literal cooking.
|
||||
const script = extractScript(buildEditorHtml(base));
|
||||
expect(script).not.toContain('\\');
|
||||
});
|
||||
|
||||
it('embeds initial content and placeholder as JSON strings', () => {
|
||||
const html = buildEditorHtml({
|
||||
...base,
|
||||
initialHtml: '<p>He said "hi" & left</p>',
|
||||
placeholder: 'Say "something"…',
|
||||
});
|
||||
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
|
||||
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
|
||||
});
|
||||
|
||||
it('keeps the editor contenteditable and the bridge object wiring', () => {
|
||||
const html = buildEditorHtml(base);
|
||||
expect(html).toContain('<div id="editor" contenteditable="true"');
|
||||
const script = extractScript(html);
|
||||
expect(script).toContain('window.__rne');
|
||||
expect(script).toContain('ReactNativeWebView.postMessage');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
|
||||
import {
|
||||
listPushSubscriptions,
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
} from '../../api/push';
|
||||
import { generateAccountId } from '../account-utils';
|
||||
@@ -74,6 +75,7 @@ function installFetch(states: Record<string, RelayState>): void {
|
||||
|
||||
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
|
||||
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
|
||||
|
||||
function sub(id: string, deviceClientId: string) {
|
||||
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
|
||||
@@ -128,4 +130,20 @@ describe('setupPushNotifications leftover reaping', () => {
|
||||
|
||||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
|
||||
});
|
||||
|
||||
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
installFetch({});
|
||||
|
||||
// Fire several overlapping setups, as App.tsx does while auth settles and
|
||||
// on FCM token refresh. Only one underlying JMAP subscription must be made.
|
||||
const results = await Promise.all([
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
]);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// Builds the self-contained HTML document for the RichTextEditor WebView.
|
||||
// Kept as a pure module (no react-native imports) so tests can assert the
|
||||
// embedded page script stays syntactically valid JavaScript.
|
||||
//
|
||||
// IMPORTANT: the page script lives inside a JS template literal. Backslash
|
||||
// escapes are "cooked" by the template (`\/` becomes `/`, `\s` becomes `s`),
|
||||
// which once shipped an invalid regex that killed the whole inline script
|
||||
// with a SyntaxError (issue #11 — Send stayed disabled because the editor
|
||||
// never posted `change` messages). Never rely on a backslash reaching the
|
||||
// page verbatim; write escape-free code or double the backslash.
|
||||
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
export const MIN_EDITOR_HEIGHT = 220;
|
||||
|
||||
export function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_EDITOR_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||
// user can't accidentally (or via a malicious paste) ship a
|
||||
// javascript:/data: link to their recipient. Written without regex
|
||||
// backslash escapes — see the template-cooking warning at the top.
|
||||
var trimmed = (url || '').trim();
|
||||
var first = trimmed.charAt(0);
|
||||
var safeScheme = /^(https?:|mailto:|tel:|sms:)/i.test(trimmed)
|
||||
|| first === '/' || first === '?' || first === '#';
|
||||
if (!trimmed || !safeScheme) return;
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -268,12 +268,36 @@ async function pollVerificationCode(
|
||||
throw new Error('Timed out waiting for PushVerification from JMAP server');
|
||||
}
|
||||
|
||||
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
|
||||
// effect as auth state settles during startup and again on every FCM token
|
||||
// refresh; because a failing attempt blocks for up to 75s polling for the
|
||||
// verification code, those re-fires overlap. Without this guard each
|
||||
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
|
||||
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
|
||||
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
|
||||
// PushVerification"). Callers share the first in-flight run instead.
|
||||
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
|
||||
|
||||
/**
|
||||
* Full setup flow: ask permission, fetch the device's FCM token, register
|
||||
* with the relay, create a JMAP PushSubscription, poll for the verification
|
||||
* code, and finalise the subscription.
|
||||
* code, and finalise the subscription. Concurrent calls for the same account
|
||||
* are coalesced onto a single in-flight run.
|
||||
*/
|
||||
export async function setupPushNotifications(
|
||||
export function setupPushNotifications(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
|
||||
const existing = inFlightSetups.get(key);
|
||||
if (existing) return existing;
|
||||
const run = setupPushNotificationsInner(params).finally(() => {
|
||||
inFlightSetups.delete(key);
|
||||
});
|
||||
inFlightSetups.set(key, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function setupPushNotificationsInner(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const native = getNative();
|
||||
|
||||
+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