mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: drafts with autosave and a save/discard close guard, Bcc, signatures, templates, sub-addressing, From override, read receipts, upload progress, inline-image hydration and an undo-send snackbar in the composer (#188, #849, #848, #540, #570, #684, #163)
This commit is contained in:
Generated
+4
-4
@@ -18,7 +18,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "^8.0.7",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-crypto": "~15.0.9",
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
@@ -5238,9 +5238,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-clipboard": {
|
"node_modules/expo-clipboard": {
|
||||||
"version": "8.0.7",
|
"version": "8.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
|
||||||
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
|
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*",
|
"expo": "*",
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "^8.0.7",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-crypto": "~15.0.9",
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
|
|||||||
+84
-8
@@ -6,6 +6,7 @@ import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './typ
|
|||||||
import { toWildcardQuery } from '../lib/search-utils';
|
import { toWildcardQuery } from '../lib/search-utils';
|
||||||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||||
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
||||||
|
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
|
||||||
|
|
||||||
export const EMAIL_LIST_PROPERTIES = [
|
export const EMAIL_LIST_PROPERTIES = [
|
||||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||||
@@ -989,6 +990,12 @@ export interface OutgoingEmail {
|
|||||||
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
|
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
requestReadReceipt?: boolean;
|
requestReadReceipt?: boolean;
|
||||||
|
/**
|
||||||
|
* SMTP MAIL FROM when it must differ from the header From - a catch-all
|
||||||
|
* alias on an owned domain is sent through the identity's envelope while
|
||||||
|
* the visible From keeps the alias (webmail #246).
|
||||||
|
*/
|
||||||
|
envelopeMailFrom?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendEmailResult {
|
export interface SendEmailResult {
|
||||||
@@ -1128,19 +1135,19 @@ export async function sendEmail(
|
|||||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
||||||
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
||||||
// server derive mailFrom from the Identity, dropping our parameter).
|
// server derive mailFrom from the Identity, dropping our parameter). An
|
||||||
if (holdForSeconds && holdForSeconds > 0) {
|
// explicit envelope sender (catch-all From override) needs it as well.
|
||||||
|
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
|
||||||
|
if (holdFor > 0 || email.envelopeMailFrom) {
|
||||||
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||||
.map((r) => r.email.trim())
|
.map((r) => r.email.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((address) => ({ email: address }));
|
.map((address) => ({ email: address }));
|
||||||
submissionCreate.envelope = {
|
const mailFrom: Record<string, unknown> = {
|
||||||
mailFrom: {
|
email: email.envelopeMailFrom || email.from[0]?.email,
|
||||||
email: email.from[0]?.email,
|
|
||||||
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
|
|
||||||
},
|
|
||||||
rcptTo,
|
|
||||||
};
|
};
|
||||||
|
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) };
|
||||||
|
submissionCreate.envelope = { mailFrom, rcptTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
const submissionArgs: Record<string, unknown> = {
|
const submissionArgs: Record<string, unknown> = {
|
||||||
@@ -1402,3 +1409,72 @@ export async function rescheduleScheduledSend(
|
|||||||
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
|
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
|
||||||
return { emailSubmissionId: created?.id, sendAt: created?.sendAt };
|
return { emailSubmissionId: created?.id, sendAt: created?.sendAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a message back into Drafts (out of Sent) and re-flag it `$draft` so
|
||||||
|
* it can be edited and re-sent - used after cancelling a scheduled send for
|
||||||
|
* editing (webmail `restoreEmailToDraft`).
|
||||||
|
*/
|
||||||
|
export async function restoreEmailToDraft(
|
||||||
|
emailId: string,
|
||||||
|
draftsMailboxId: string,
|
||||||
|
sentMailboxId?: string,
|
||||||
|
accountIdOverride?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
[mailboxPointer(draftsMailboxId)]: true,
|
||||||
|
[keywordPointer('$draft')]: true,
|
||||||
|
[keywordPointer('$seen')]: true,
|
||||||
|
};
|
||||||
|
if (sentMailboxId && sentMailboxId !== draftsMailboxId) patch[mailboxPointer(sentMailboxId)] = null;
|
||||||
|
await emailSetBatched(accountId, { update: { [emailId]: patch } }, 'draft');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendReadReceiptOptions extends MdnOptions {
|
||||||
|
/** Identity the receipt is submitted through (its address is the MDN From). */
|
||||||
|
identityId: string;
|
||||||
|
/** Where the sent receipt is filed. */
|
||||||
|
sentMailboxId: string;
|
||||||
|
/** Submitting account (shared/group account); defaults to the primary. */
|
||||||
|
accountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an RFC 8098 read receipt (MDN). JMAP has no MDN primitive, so the
|
||||||
|
* multipart/report is built client-side (`lib/mdn.ts`), uploaded as a blob,
|
||||||
|
* imported into Sent and submitted with an explicit envelope - mirrors the
|
||||||
|
* webmail's `client.sendReadReceipt`. The caller flags the original
|
||||||
|
* `$mdnsent` afterwards.
|
||||||
|
*/
|
||||||
|
export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<string> {
|
||||||
|
const accountId = opts.accountId ?? jmapClient.accountId;
|
||||||
|
const raw = buildMdnMessage(opts);
|
||||||
|
const bytes = new TextEncoder().encode(raw);
|
||||||
|
// Lazy: blob.ts pulls in expo-file-system, which this module otherwise
|
||||||
|
// never needs (and which the node test environment cannot load).
|
||||||
|
const { uploadBytes } = await import('./blob');
|
||||||
|
const upload = await uploadBytes(bytes, 'message/rfc822', accountId);
|
||||||
|
const emailId = await importEmailBlob(upload.blobId, opts.sentMailboxId, { $seen: true }, accountId);
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[
|
||||||
|
['EmailSubmission/set', {
|
||||||
|
accountId,
|
||||||
|
create: {
|
||||||
|
mdn: {
|
||||||
|
emailId,
|
||||||
|
identityId: opts.identityId,
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { email: opts.fromEmail },
|
||||||
|
rcptTo: [{ email: opts.to }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, '0'],
|
||||||
|
],
|
||||||
|
SUBMISSION_USING,
|
||||||
|
);
|
||||||
|
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
|
||||||
|
assertSetResult(body, ['mdn'], 'read receipt');
|
||||||
|
return emailId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export type RichTextCommand =
|
|||||||
| 'justifyRight'
|
| 'justifyRight'
|
||||||
| 'removeFormat'
|
| 'removeFormat'
|
||||||
| 'undo'
|
| 'undo'
|
||||||
| 'redo';
|
| 'redo'
|
||||||
|
// Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'.
|
||||||
|
| `foreColor:${string}`;
|
||||||
|
|
||||||
export interface RichTextSelectionState {
|
export interface RichTextSelectionState {
|
||||||
bold: boolean;
|
bold: boolean;
|
||||||
@@ -50,6 +52,8 @@ export interface RichTextEditorHandle {
|
|||||||
insertLink(url: string, label?: string): void;
|
insertLink(url: string, label?: string): void;
|
||||||
unsetLink(): void;
|
unsetLink(): void;
|
||||||
insertImage(src: string, cid?: string, alt?: string): void;
|
insertImage(src: string, cid?: string, alt?: string): void;
|
||||||
|
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
||||||
|
insertHtml(html: string): void;
|
||||||
setHtml(html: string): void;
|
setHtml(html: string): void;
|
||||||
/**
|
/**
|
||||||
* Read the live editor DOM content. Resolves with the current innerHTML or
|
* Read the live editor DOM content. Resolves with the current innerHTML or
|
||||||
@@ -97,7 +101,17 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Scripts injected before the page has run its inline script are lost, and
|
||||||
|
// the composer talks to the editor as soon as async data arrives (signature
|
||||||
|
// after the identities load, hydrated inline images). Queue until the page
|
||||||
|
// posts its first message, then replay in order.
|
||||||
|
const readyRef = React.useRef(false);
|
||||||
|
const queuedRef = React.useRef<string[]>([]);
|
||||||
const call = React.useCallback((script: string) => {
|
const call = React.useCallback((script: string) => {
|
||||||
|
if (!readyRef.current) {
|
||||||
|
queuedRef.current.push(script);
|
||||||
|
return;
|
||||||
|
}
|
||||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -124,7 +138,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
exec: (command) => {
|
exec: (command) => {
|
||||||
const [name, value] = command.split(':');
|
const sep = command.indexOf(':');
|
||||||
|
const name = sep === -1 ? command : command.slice(0, sep);
|
||||||
|
const value = sep === -1 ? undefined : command.slice(sep + 1);
|
||||||
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
|
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
|
||||||
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
|
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
|
||||||
},
|
},
|
||||||
@@ -137,6 +153,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
insertImage: (src, cid, alt) => {
|
insertImage: (src, cid, alt) => {
|
||||||
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
|
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
|
||||||
},
|
},
|
||||||
|
insertHtml: (html) => {
|
||||||
|
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
|
||||||
|
},
|
||||||
setHtml: (next) => {
|
setHtml: (next) => {
|
||||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||||
},
|
},
|
||||||
@@ -162,6 +181,12 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!readyRef.current) {
|
||||||
|
readyRef.current = true;
|
||||||
|
const queued = queuedRef.current;
|
||||||
|
queuedRef.current = [];
|
||||||
|
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||||
|
}
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'change':
|
case 'change':
|
||||||
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
|
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { X, Star, FileText } 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 { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
|
||||||
|
import { filterTemplates } from '../lib/template-utils';
|
||||||
|
|
||||||
|
interface TemplateSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (template: EmailTemplate) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet listing the user's templates for insertion into the composer
|
||||||
|
* (webmail template picker): favourites first, then the rest, with a search
|
||||||
|
* box over name/subject/category.
|
||||||
|
*/
|
||||||
|
export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const templates = useTemplatesStore((s) => s.templates);
|
||||||
|
const hydrated = useTemplatesStore((s) => s.hydrated);
|
||||||
|
const hydrate = useTemplatesStore((s) => s.hydrate);
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
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 });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hydrated) void hydrate();
|
||||||
|
}, [hydrated, hydrate]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setQuery('');
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const visibleTemplates = React.useMemo(() => {
|
||||||
|
const list = query.trim() ? filterTemplates(templates, query.trim()) : templates;
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}, [templates, query]);
|
||||||
|
|
||||||
|
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('templates.picker_title', 'Choose a Template')}</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||||
|
<X size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{templates.length > 4 && (
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder={t('templates.search_placeholder', 'Search templates...')}
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
style={styles.search}
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ScrollView style={styles.scrollList} keyboardShouldPersistTaps="handled">
|
||||||
|
{visibleTemplates.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>
|
||||||
|
{templates.length === 0
|
||||||
|
? t('templates.no_templates', 'No templates yet')
|
||||||
|
: t('templates.no_results', 'No templates found')}
|
||||||
|
</Text>
|
||||||
|
) : visibleTemplates.map((template) => (
|
||||||
|
<Pressable
|
||||||
|
key={template.id}
|
||||||
|
onPress={() => {
|
||||||
|
onPick(template);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
>
|
||||||
|
<View style={styles.iconWrap}>
|
||||||
|
{template.isFavorite
|
||||||
|
? <Star size={16} color={c.primary} />
|
||||||
|
: <FileText size={16} color={c.textSecondary} />}
|
||||||
|
</View>
|
||||||
|
<View style={styles.details}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>{template.name}</Text>
|
||||||
|
{!!(template.subject || template.category) && (
|
||||||
|
<Text style={styles.subject} numberOfLines={1}>
|
||||||
|
{[template.category, template.subject].filter(Boolean).join(' · ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</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: '70%',
|
||||||
|
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 },
|
||||||
|
search: {
|
||||||
|
...typography.body,
|
||||||
|
color: c.text,
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
backgroundColor: c.surface,
|
||||||
|
},
|
||||||
|
scrollList: { paddingVertical: spacing.xs },
|
||||||
|
empty: { ...typography.body, color: c.textMuted, textAlign: 'center', padding: spacing.xl },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
minHeight: 52,
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
|
iconWrap: {
|
||||||
|
marginRight: spacing.md,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: radius.xs,
|
||||||
|
backgroundColor: c.surface,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
details: { flex: 1, justifyContent: 'center' },
|
||||||
|
name: { ...typography.bodyMedium, color: c.text },
|
||||||
|
subject: { ...typography.caption, color: c.textSecondary, marginTop: 2 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,33 +1,72 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
|
import { useSendUndoStore, type PendingUndoSend } from '../stores/send-undo-store';
|
||||||
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { getFullEmail, restoreEmailToDraft } from '../api/email';
|
||||||
|
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||||
|
import { draftContextFromEmail } from '../lib/draft-context';
|
||||||
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
|
||||||
const VISIBLE_MS = 5000;
|
const VISIBLE_MS = 5000;
|
||||||
|
|
||||||
|
interface Shown {
|
||||||
|
label: string;
|
||||||
|
createdAt: number;
|
||||||
|
visibleMs: number;
|
||||||
|
/** Undo-send entries carry a second "Send now" action. */
|
||||||
|
send?: PendingUndoSend;
|
||||||
|
}
|
||||||
|
|
||||||
export function UndoSnackbar() {
|
export function UndoSnackbar() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const entry = useEmailStore((s) => s.pendingUndo);
|
const entry = useEmailStore((s) => s.pendingUndo);
|
||||||
const undoLast = useEmailStore((s) => s.undoLast);
|
const undoLast = useEmailStore((s) => s.undoLast);
|
||||||
const clearUndo = useEmailStore((s) => s.clearUndo);
|
const clearUndo = useEmailStore((s) => s.clearUndo);
|
||||||
|
const pendingSend = useSendUndoStore((s) => s.pending);
|
||||||
|
const sendBusy = useSendUndoStore((s) => s.busy);
|
||||||
const slideY = React.useRef(new Animated.Value(120)).current;
|
const slideY = React.useRef(new Animated.Value(120)).current;
|
||||||
const opacity = React.useRef(new Animated.Value(0)).current;
|
const opacity = React.useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// The undo-send entry (composer) and the list-action entry (store) share
|
||||||
|
// the bar; the newer one wins.
|
||||||
|
const current = React.useMemo<Shown | null>(() => {
|
||||||
|
const list: Shown | null = entry
|
||||||
|
? { label: entry.label, createdAt: entry.createdAt, visibleMs: VISIBLE_MS }
|
||||||
|
: null;
|
||||||
|
const send: Shown | null = pendingSend
|
||||||
|
? {
|
||||||
|
label: t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: pendingSend.delaySeconds }),
|
||||||
|
createdAt: pendingSend.createdAt,
|
||||||
|
visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000),
|
||||||
|
send: pendingSend,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (list && send) return list.createdAt >= send.createdAt ? list : send;
|
||||||
|
return list ?? send;
|
||||||
|
}, [entry, pendingSend, t]);
|
||||||
|
|
||||||
// Remember the last non-null entry so the bar's text/handlers stay valid
|
// Remember the last non-null entry so the bar's text/handlers stay valid
|
||||||
// while it animates out after pendingUndo flips to null.
|
// while it animates out after the entry flips to null.
|
||||||
const [shown, setShown] = React.useState(entry);
|
const [shown, setShown] = React.useState(current);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (entry) setShown(entry);
|
if (current) setShown(current);
|
||||||
}, [entry]);
|
}, [current]);
|
||||||
|
|
||||||
// Drive the auto-dismiss off the entry's createdAt so re-renders during
|
// Drive the auto-dismiss off the entry's createdAt so re-renders during
|
||||||
// animation don't reset the timer, and so consecutive actions reset it.
|
// animation don't reset the timer, and so consecutive actions reset it.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!entry) {
|
if (!current) {
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||||
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||||
@@ -42,16 +81,44 @@ export function UndoSnackbar() {
|
|||||||
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
const elapsed = Date.now() - entry.createdAt;
|
const elapsed = Date.now() - current.createdAt;
|
||||||
const remaining = Math.max(0, VISIBLE_MS - elapsed);
|
const remaining = Math.max(0, current.visibleMs - elapsed);
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
// Only clear if the entry is still the same one we scheduled for.
|
if (current.send) {
|
||||||
if (useEmailStore.getState().pendingUndo === entry) {
|
if (useSendUndoStore.getState().pending === current.send) useSendUndoStore.getState().clear();
|
||||||
|
} else if (useEmailStore.getState().pendingUndo === entry) {
|
||||||
|
// Only clear if the entry is still the same one we scheduled for.
|
||||||
clearUndo();
|
clearUndo();
|
||||||
}
|
}
|
||||||
}, remaining);
|
}, remaining);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [entry, slideY, opacity, clearUndo]);
|
}, [current, entry, slideY, opacity, clearUndo]);
|
||||||
|
|
||||||
|
const onUndo = async () => {
|
||||||
|
if (!shown) return;
|
||||||
|
if (!shown.send) {
|
||||||
|
void undoLast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const send = shown.send;
|
||||||
|
const ok = await useSendUndoStore.getState().undo();
|
||||||
|
if (!ok) return;
|
||||||
|
// Bring the message back as an editable draft and reopen the composer.
|
||||||
|
try {
|
||||||
|
const own = ownMailboxes(useEmailStore.getState().mailboxes);
|
||||||
|
const drafts = own.find((m) => m.role === 'drafts');
|
||||||
|
const sent = own.find((m) => m.role === 'sent');
|
||||||
|
if (drafts) await restoreEmailToDraft(send.emailId, drafts.id, sent?.id);
|
||||||
|
const email = await getFullEmail(send.emailId);
|
||||||
|
navigation.navigate('Compose', { draft: draftContextFromEmail(email) });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[send-undo] reopen failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSendNow = () => {
|
||||||
|
void useSendUndoStore.getState().sendNow();
|
||||||
|
};
|
||||||
|
|
||||||
if (!shown) return null;
|
if (!shown) return null;
|
||||||
|
|
||||||
@@ -65,12 +132,23 @@ export function UndoSnackbar() {
|
|||||||
>
|
>
|
||||||
<View style={styles.bar}>
|
<View style={styles.bar}>
|
||||||
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
|
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
|
||||||
|
{shown.send && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onSendNow}
|
||||||
|
hitSlop={8}
|
||||||
|
disabled={sendBusy}
|
||||||
|
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.sendNowText}>{t('email_composer.send_now', 'Send now').toUpperCase()}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => { void undoLast(); }}
|
onPress={() => { void onUndo(); }}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
|
disabled={sendBusy}
|
||||||
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||||
>
|
>
|
||||||
<Text style={styles.undoText}>UNDO</Text>
|
<Text style={styles.undoText}>{t('common.undo', 'Undo').toUpperCase()}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -117,5 +195,11 @@ function makeStyles(c: ThemePalette) {
|
|||||||
color: c.primary,
|
color: c.primary,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
sendNowText: {
|
||||||
|
...typography.bodySemibold,
|
||||||
|
color: c.background,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function buildEditorHtml(opts: {
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var editor = document.getElementById('editor');
|
var editor = document.getElementById('editor');
|
||||||
|
|||||||
+1659
-524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
// A message sent with the undo-send delay: the submission is held by the
|
||||||
|
// server (HOLDFOR) and can still be cancelled or released early. The composer
|
||||||
|
// records it here after a delayed send; the UndoSnackbar on the mail list
|
||||||
|
// offers "Undo" / "Send now" for the length of the window (webmail
|
||||||
|
// `pendingUndoSend`, changelog 1.7.0).
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { cancelScheduledSend, rescheduleScheduledSend } from '../api/email';
|
||||||
|
import type { EmailAddress } from '../api/types';
|
||||||
|
|
||||||
|
export interface PendingUndoSend {
|
||||||
|
emailSubmissionId: string;
|
||||||
|
emailId: string;
|
||||||
|
identityId: string;
|
||||||
|
from?: EmailAddress[];
|
||||||
|
to?: EmailAddress[];
|
||||||
|
/** ISO time the server will release the message. */
|
||||||
|
sendAt?: string;
|
||||||
|
/** Milliseconds the message is held (drives the snackbar timer). */
|
||||||
|
delaySeconds: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendUndoState {
|
||||||
|
pending: PendingUndoSend | null;
|
||||||
|
busy: boolean;
|
||||||
|
/** Set after an undo so the composer can be reopened with the draft. */
|
||||||
|
restoredEmailId: string | null;
|
||||||
|
setPending: (entry: PendingUndoSend) => void;
|
||||||
|
clear: () => void;
|
||||||
|
/** Cancel delivery. The message stays in Sent; the caller may re-open it. */
|
||||||
|
undo: () => Promise<boolean>;
|
||||||
|
/** Release the held message immediately. */
|
||||||
|
sendNow: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||||
|
pending: null,
|
||||||
|
busy: false,
|
||||||
|
restoredEmailId: null,
|
||||||
|
|
||||||
|
setPending: (entry) => set({ pending: entry, restoredEmailId: null }),
|
||||||
|
clear: () => set({ pending: null, busy: false }),
|
||||||
|
|
||||||
|
undo: async () => {
|
||||||
|
const entry = get().pending;
|
||||||
|
if (!entry || get().busy) return false;
|
||||||
|
set({ busy: true });
|
||||||
|
try {
|
||||||
|
await cancelScheduledSend(entry.emailSubmissionId);
|
||||||
|
set({ pending: null, busy: false, restoredEmailId: entry.emailId });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[send-undo] cancel failed', err);
|
||||||
|
set({ busy: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendNow: async () => {
|
||||||
|
const entry = get().pending;
|
||||||
|
if (!entry || get().busy) return false;
|
||||||
|
set({ busy: true });
|
||||||
|
try {
|
||||||
|
await rescheduleScheduledSend(
|
||||||
|
{
|
||||||
|
emailSubmissionId: entry.emailSubmissionId,
|
||||||
|
emailId: entry.emailId,
|
||||||
|
identityId: entry.identityId,
|
||||||
|
from: entry.from,
|
||||||
|
to: entry.to,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
set({ pending: null, busy: false });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[send-undo] send now failed', err);
|
||||||
|
set({ busy: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user